From a05985dc03bc62b26f2e2f18ef60018ca445f84e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 13 Aug 2026 17:49:58 +0300 Subject: [PATCH 01/60] [ADD] ai_tool: port module to 19.0 --- ai_tool/README.rst | 145 ++++++++ ai_tool/__init__.py | 2 + ai_tool/__manifest__.py | 22 ++ ai_tool/data/ai_tools.xml | 20 ++ ai_tool/i18n/ai_tool.pot | 96 +++++ ai_tool/i18n/es.po | 102 ++++++ ai_tool/i18n/it.po | 104 ++++++ ai_tool/models/__init__.py | 1 + ai_tool/models/ai_tool.py | 94 +++++ ai_tool/pyproject.toml | 3 + ai_tool/readme/CONTRIBUTORS.md | 2 + ai_tool/readme/DESCRIPTION.md | 3 + ai_tool/readme/USAGE.md | 52 +++ ai_tool/security/ir.model.access.csv | 2 + ai_tool/static/description/icon.png | Bin 0 -> 9455 bytes ai_tool/static/description/index.html | 489 ++++++++++++++++++++++++++ ai_tool/tests/__init__.py | 1 + ai_tool/tests/test_ai_tool.py | 49 +++ ai_tool/tools.py | 18 + ai_tool/views/ai_tool.xml | 53 +++ ai_tool/views/menu.xml | 6 + 21 files changed, 1264 insertions(+) create mode 100644 ai_tool/README.rst create mode 100644 ai_tool/__init__.py create mode 100644 ai_tool/__manifest__.py create mode 100644 ai_tool/data/ai_tools.xml create mode 100644 ai_tool/i18n/ai_tool.pot create mode 100644 ai_tool/i18n/es.po create mode 100644 ai_tool/i18n/it.po create mode 100644 ai_tool/models/__init__.py create mode 100644 ai_tool/models/ai_tool.py create mode 100644 ai_tool/pyproject.toml create mode 100644 ai_tool/readme/CONTRIBUTORS.md create mode 100644 ai_tool/readme/DESCRIPTION.md create mode 100644 ai_tool/readme/USAGE.md create mode 100644 ai_tool/security/ir.model.access.csv create mode 100644 ai_tool/static/description/icon.png create mode 100644 ai_tool/static/description/index.html create mode 100644 ai_tool/tests/__init__.py create mode 100644 ai_tool/tests/test_ai_tool.py create mode 100644 ai_tool/tools.py create mode 100644 ai_tool/views/ai_tool.xml create mode 100644 ai_tool/views/menu.xml diff --git a/ai_tool/README.rst b/ai_tool/README.rst new file mode 100644 index 00000000..c83a777d --- /dev/null +++ b/ai_tool/README.rst @@ -0,0 +1,145 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +======= +Ai Tool +======= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:84d38912b0ba3a8c32a3cb1957183d7cb15fd310cc91c81e76f4d5eca7b731e7 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_tool + :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_tool + :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 technical module provides the base infrastructure for defining AI +tools in Odoo. It allows other modules to register callable functions as +AI tools, which can then be used by MCP servers, automation flows, or +any AI-native integration. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +This module is technical, however, it adds some specific functions that +might be used in glue modules. + +For example, if we want to add on sales a functionality to find the +sales on a period, we should do: + +.. code:: xml + + + + total_sale_order + Calculate the total amount of sale orders within a date range and optionally for a specific customer. + + _mcp_total_sale_order + + + +.. code:: python + + from odoo.addons.ai_tool.tools import aitool + + class SaleOrder(models.Model): + _inherit = "sale.order" + + @aitool( + input_schema={ + "start_date": {"type": "date"}, + "end_date": {"type": "date"}, + "customer_id": {"type": "integer"}, + }, + required_inputs=["start_date", "end_date"], + output_schema={ + "amount_total": {"type": "number"}, + }, + ) + def _mcp_total_sale_order(self, start_date, end_date, customer_id=None): + domain = [("date_order", ">=", start_date), ("date_order", "<=", end_date)] + if customer_id: + domain.append(("partner_id", "=", customer_id)) + orders = self.read_group(domain, ["amount_total"], []) + return { + "amount_total": (orders[0]["amount_total"] or 0) if orders else 0, + } + +Be aware that this kind of elements must allways return a dict. All the +elements will be defined in output_schema. + +Also, for the signature of the functions, all fields must be in the +inputs with the exception of record. This argument is protected and is +used to define integrations with automation. This argument is required +in ``generic_model`` and ``record`` tools. + +On ``generic_model``\ s we are expecting this value because we want to +do a specific action with the model. + +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_tool/__init__.py b/ai_tool/__init__.py new file mode 100644 index 00000000..738a2eec --- /dev/null +++ b/ai_tool/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import tools diff --git a/ai_tool/__manifest__.py b/ai_tool/__manifest__.py new file mode 100644 index 00000000..523cc55f --- /dev/null +++ b/ai_tool/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Ai Tool", + "summary": """We want to generate some specific AI Tools + that might be used in other places, like MCP or native.""", + "version": "19.0.1.0.0", + "license": "AGPL-3", + "author": "Dixmit,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/ai", + "depends": [ + "mail", + ], + "data": [ + "security/ir.model.access.csv", + "views/menu.xml", + "views/ai_tool.xml", + "data/ai_tools.xml", + ], + "demo": [], +} diff --git a/ai_tool/data/ai_tools.xml b/ai_tool/data/ai_tools.xml new file mode 100644 index 00000000..e6f7ce5a --- /dev/null +++ b/ai_tool/data/ai_tools.xml @@ -0,0 +1,20 @@ + + + + + get_date + Get the current date. + + _ai_get_date + generic + + + + post_message + Post a message to a record. + + _ai_post_message + generic_model + + diff --git a/ai_tool/i18n/ai_tool.pot b/ai_tool/i18n/ai_tool.pot new file mode 100644 index 00000000..bd7a82ea --- /dev/null +++ b/ai_tool/i18n/ai_tool.pot @@ -0,0 +1,96 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * ai_tool +# +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_tool +#: model:ir.ui.menu,name:ai_tool.ai_menu_root +msgid "AI" +msgstr "" + +#. module: ai_tool +#: model:ir.actions.act_window,name:ai_tool.ai_tool_act_window +#: model:ir.model,name:ai_tool.model_ai_tool +#: model:ir.ui.menu,name:ai_tool.ai_tool_menu +msgid "AI Tool" +msgstr "" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__create_uid +msgid "Created by" +msgstr "" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__create_date +msgid "Created on" +msgstr "" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__description +msgid "Description" +msgstr "" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__display_name +msgid "Display Name" +msgstr "" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__function_name +msgid "Function Name" +msgstr "" + +#. module: ai_tool +#: model:ir.model.fields.selection,name:ai_tool.selection__ai_tool__kind__generic +msgid "Generic" +msgstr "" + +#. module: ai_tool +#: model:ir.model.fields.selection,name:ai_tool.selection__ai_tool__kind__generic_model +msgid "Generic but requires a record to work" +msgstr "" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__id +msgid "ID" +msgstr "" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__kind +msgid "Kind" +msgstr "" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__write_date +msgid "Last Updated on" +msgstr "" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__model_id +msgid "Model" +msgstr "" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__name +msgid "Name" +msgstr "" + +#. module: ai_tool +#: model:ir.model.fields.selection,name:ai_tool.selection__ai_tool__kind__record +msgid "Record" +msgstr "" diff --git a/ai_tool/i18n/es.po b/ai_tool/i18n/es.po new file mode 100644 index 00000000..eb6796ef --- /dev/null +++ b/ai_tool/i18n/es.po @@ -0,0 +1,102 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * ai_tool +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2026-07-24 17:27+0000\n" +"Last-Translator: Ed-Spain \n" +"Language-Team: none\n" +"Language: es\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_tool +#: model:ir.ui.menu,name:ai_tool.ai_menu_root +msgid "AI" +msgstr "IA" + +#. module: ai_tool +#: model:ir.actions.act_window,name:ai_tool.ai_tool_act_window +#: model:ir.model,name:ai_tool.model_ai_tool +#: model:ir.ui.menu,name:ai_tool.ai_tool_menu +msgid "AI Tool" +msgstr "Herramienta IA" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__create_date +msgid "Created on" +msgstr "Creada el" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__description +msgid "Description" +msgstr "Descripción" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__display_name +msgid "Display Name" +msgstr "Nombre a Mostrar" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__function_name +msgid "Function Name" +msgstr "Nombre de la Función" + +#. module: ai_tool +#: model:ir.model.fields.selection,name:ai_tool.selection__ai_tool__kind__generic +msgid "Generic" +msgstr "Genérico" + +#. module: ai_tool +#: model:ir.model.fields.selection,name:ai_tool.selection__ai_tool__kind__generic_model +msgid "Generic but requires a record to work" +msgstr "Genérico pero requiere un registro para funcionar" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__id +msgid "ID" +msgstr "ID" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__kind +msgid "Kind" +msgstr "Tipo" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__write_uid +#, fuzzy +msgid "Last Updated by" +msgstr "Modificado por última vez por" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__write_date +msgid "Last Updated on" +msgstr "Actualizado por Última vez el" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__model_id +#, fuzzy +msgid "Model" +msgstr "Modelo" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__name +msgid "Name" +msgstr "Nombre" + +#. module: ai_tool +#: model:ir.model.fields.selection,name:ai_tool.selection__ai_tool__kind__record +#, fuzzy +msgid "Record" +msgstr "Registro" diff --git a/ai_tool/i18n/it.po b/ai_tool/i18n/it.po new file mode 100644 index 00000000..20aa0c1b --- /dev/null +++ b/ai_tool/i18n/it.po @@ -0,0 +1,104 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * ai_tool +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2026-07-01 11: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_tool +#: model:ir.ui.menu,name:ai_tool.ai_menu_root +msgid "AI" +msgstr "IA" + +#. module: ai_tool +#: model:ir.actions.act_window,name:ai_tool.ai_tool_act_window +#: model:ir.model,name:ai_tool.model_ai_tool +#: model:ir.ui.menu,name:ai_tool.ai_tool_menu +msgid "AI Tool" +msgstr "Strumento IA" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__description +msgid "Description" +msgstr "Descrizione" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__function_name +msgid "Function Name" +msgstr "Nome funzione" + +#. module: ai_tool +#: model:ir.model.fields.selection,name:ai_tool.selection__ai_tool__kind__generic +msgid "Generic" +msgstr "Generico" + +#. module: ai_tool +#: model:ir.model.fields.selection,name:ai_tool.selection__ai_tool__kind__generic_model +msgid "Generic but requires a record to work" +msgstr "Generico ma richiede un record per lavorare" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__id +msgid "ID" +msgstr "ID" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__kind +msgid "Kind" +msgstr "Genere" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool____last_update +msgid "Last Modified on" +msgstr "Ultima modifica il" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__write_date +msgid "Last Updated on" +msgstr "Ultimo aggiornamento il" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__model_id +msgid "Model" +msgstr "Modello" + +#. module: ai_tool +#: model:ir.model.fields,field_description:ai_tool.field_ai_tool__name +msgid "Name" +msgstr "Nome" + +#. module: ai_tool +#: model:ir.model.fields.selection,name:ai_tool.selection__ai_tool__kind__record +msgid "Record" +msgstr "Record" diff --git a/ai_tool/models/__init__.py b/ai_tool/models/__init__.py new file mode 100644 index 00000000..b6c10aa8 --- /dev/null +++ b/ai_tool/models/__init__.py @@ -0,0 +1 @@ +from . import ai_tool diff --git a/ai_tool/models/ai_tool.py b/ai_tool/models/ai_tool.py new file mode 100644 index 00000000..68cd76d3 --- /dev/null +++ b/ai_tool/models/ai_tool.py @@ -0,0 +1,94 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import date + +from odoo import fields, models +from odoo.tools.mail import html_sanitize, plaintext2html + +from ..tools import aitool + +try: + import markdown +except ImportError: + markdown = None + + +class AiTool(models.Model): + _name = "ai.tool" + _description = "AI Tool" + + name = fields.Char(required=True) + description = fields.Text() + model_id = fields.Many2one( + "ir.model", readonly=True, required=True, ondelete="cascade" + ) + function_name = fields.Char(readonly=True, required=True) + kind = fields.Selection( + [ + ("generic", "Generic"), + ("generic_model", "Generic but requires a record to work"), + ("record", "Record"), + ], + readonly=True, + required=True, + default="record", + ) + + def _get_tool_definition(self): + func = getattr(self.env[self.model_id.model], self.function_name) + return { + "name": self.name, + "description": self.description, + "inputSchema": func._ai_tool["input_schema"], + "outputSchema": func._ai_tool["output_schema"], + } + + @aitool( + input_schema={}, + output_schema={ + "date": {"type": "date"}, + }, + ) + def _ai_get_date(self): + return {"date": date.today().isoformat()} + + @aitool( + input_schema={ + "message": {"type": "string"}, + }, + required_inputs=["message"], + output_schema={}, + ) + def _ai_post_message(self, message=None, record=None, **kwargs): + if not record or not record.exists(): + raise ValueError("Record must be provided and exist to post a message") + record.message_post(body=self._ai_post_message_parse_body(message)) + return {} + + def _ai_post_message_parse_body(self, message): + """ + Using markdown library if available to convert markdown to html, + otherwise using plaintext2html as fallback + """ + if markdown: + return html_sanitize(markdown.markdown(message)) + return plaintext2html(message) + + def _execute_tool(self, *args, record=None, **kwargs): + if self.kind == "generic": + return getattr(self.env[self.model_id.model], self.function_name)( + *args, **kwargs + ) + if not record: + raise ValueError("Record must be provided for non-generic tools") + if self.kind == "generic_model": + return getattr(self.env[self.model_id.model], self.function_name)( + *args, record=record, **kwargs + ) + elif record._name != self.model_id.model: + raise ValueError( + f"Record model {record._name} does not match tool " + f"model {self.model_id.model}" + ) + return getattr(record, self.function_name)(*args, **kwargs) or {} diff --git a/ai_tool/pyproject.toml b/ai_tool/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/ai_tool/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/ai_tool/readme/CONTRIBUTORS.md b/ai_tool/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..2c066ba7 --- /dev/null +++ b/ai_tool/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Dixmit](https://www.dixmit.com) + - Enric Tobella diff --git a/ai_tool/readme/DESCRIPTION.md b/ai_tool/readme/DESCRIPTION.md new file mode 100644 index 00000000..71747f87 --- /dev/null +++ b/ai_tool/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This technical module provides the base infrastructure for defining AI tools in Odoo. +It allows other modules to register callable functions as AI tools, which can then be +used by MCP servers, automation flows, or any AI-native integration. diff --git a/ai_tool/readme/USAGE.md b/ai_tool/readme/USAGE.md new file mode 100644 index 00000000..c7f118c5 --- /dev/null +++ b/ai_tool/readme/USAGE.md @@ -0,0 +1,52 @@ +This module is technical, however, it adds some specific functions that might be used in glue modules. + +For example, if we want to add on sales a functionality to find the sales on a period, we should do: + +```xml + + + total_sale_order + Calculate the total amount of sale orders within a date range and optionally for a specific customer. + + _mcp_total_sale_order + + +``` + +```python +from odoo.addons.ai_tool.tools import aitool + +class SaleOrder(models.Model): + _inherit = "sale.order" + + @aitool( + input_schema={ + "start_date": {"type": "date"}, + "end_date": {"type": "date"}, + "customer_id": {"type": "integer"}, + }, + required_inputs=["start_date", "end_date"], + output_schema={ + "amount_total": {"type": "number"}, + }, + ) + def _mcp_total_sale_order(self, start_date, end_date, customer_id=None): + domain = [("date_order", ">=", start_date), ("date_order", "<=", end_date)] + if customer_id: + domain.append(("partner_id", "=", customer_id)) + orders = self.read_group(domain, ["amount_total"], []) + return { + "amount_total": (orders[0]["amount_total"] or 0) if orders else 0, + } + +``` + +Be aware that this kind of elements must allways return a dict. All the elements will be defined in output_schema. + +Also, for the signature of the functions, all fields must be in the inputs with the exception of record. +This argument is protected and is used to define integrations with automation. +This argument is required in `generic_model` and `record` tools. + +On `generic_model`s we are expecting this value because we want to do a specific action with the model. diff --git a/ai_tool/security/ir.model.access.csv b/ai_tool/security/ir.model.access.csv new file mode 100644 index 00000000..4f6beed7 --- /dev/null +++ b/ai_tool/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ai_tool,access_ai_tool,model_ai_tool,base.group_user,1,0,0,0 diff --git a/ai_tool/static/description/icon.png b/ai_tool/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/ai_tool/static/description/index.html b/ai_tool/static/description/index.html new file mode 100644 index 00000000..96b047ab --- /dev/null +++ b/ai_tool/static/description/index.html @@ -0,0 +1,489 @@ + + + + + +Ai Tool + + + +
+ + + +Odoo Community Association + +
+

Ai Tool

+ +

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

+

This technical module provides the base infrastructure for defining AI +tools in Odoo. It allows other modules to register callable functions as +AI tools, which can then be used by MCP servers, automation flows, or +any AI-native integration.

+

Table of contents

+ +
+

Usage

+

This module is technical, however, it adds some specific functions that +might be used in glue modules.

+

For example, if we want to add on sales a functionality to find the +sales on a period, we should do:

+
+<odoo>
+    <record model="ai.tool" id="total_sale_order_tool">
+        <field name="name">total_sale_order</field>
+        <field
+            name="description"
+        >Calculate the total amount of sale orders within a date range and optionally for a specific customer.</field>
+        <field name="model_id" ref="model_sale_order" />
+        <field name="function_name">_mcp_total_sale_order</field>
+    </record>
+</odoo>
+
+
+from odoo.addons.ai_tool.tools import aitool
+
+class SaleOrder(models.Model):
+    _inherit = "sale.order"
+
+    @aitool(
+        input_schema={
+            "start_date": {"type": "date"},
+            "end_date": {"type": "date"},
+            "customer_id": {"type": "integer"},
+        },
+        required_inputs=["start_date", "end_date"],
+        output_schema={
+            "amount_total": {"type": "number"},
+        },
+    )
+    def _mcp_total_sale_order(self, start_date, end_date, customer_id=None):
+        domain = [("date_order", ">=", start_date), ("date_order", "<=", end_date)]
+        if customer_id:
+            domain.append(("partner_id", "=", customer_id))
+        orders = self.read_group(domain, ["amount_total"], [])
+        return {
+            "amount_total": (orders[0]["amount_total"] or 0) if orders else 0,
+        }
+
+

Be aware that this kind of elements must allways return a dict. All the +elements will be defined in output_schema.

+

Also, for the signature of the functions, all fields must be in the +inputs with the exception of record. This argument is protected and is +used to define integrations with automation. This argument is required +in generic_model and record tools.

+

On generic_models we are expecting this value because we want to +do a specific action with the model.

+
+
+

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_tool/tests/__init__.py b/ai_tool/tests/__init__.py new file mode 100644 index 00000000..30e42873 --- /dev/null +++ b/ai_tool/tests/__init__.py @@ -0,0 +1 @@ +from . import test_ai_tool diff --git a/ai_tool/tests/test_ai_tool.py b/ai_tool/tests/test_ai_tool.py new file mode 100644 index 00000000..c9090aaf --- /dev/null +++ b/ai_tool/tests/test_ai_tool.py @@ -0,0 +1,49 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from freezegun import freeze_time + +from odoo.tests.common import TransactionCase + + +class TestAiTool(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Test Partner"}) + + def test_tool(self): + definition = self.env.ref("ai_tool.current_date")._get_tool_definition() + self.assertEqual(definition["inputSchema"]["type"], "object") + self.assertEqual(definition["outputSchema"]["type"], "object") + + def test_get_date_tool(self): + with freeze_time("2024-01-01"): + tool = self.env.ref("ai_tool.current_date") + result = tool._execute_tool(record=tool) + self.assertEqual(result["date"], "2024-01-01") + + def test_post_message_tool(self): + partner = self.partner + messages = partner.message_ids + tool = self.env.ref("ai_tool.post_message") + tool._execute_tool(message="Hello World", record=partner) + self.assertEqual(len(partner.message_ids), len(messages) + 1) + self.assertRegex((partner.message_ids - messages).body, "Hello World") + + def test_tool_no_record(self): + tool = self.env.ref("ai_tool.post_message") + with self.assertRaises(ValueError): + tool._execute_tool(message="Hello World") + + def test_post_message_tool_no_record(self): + tool = self.env.ref("ai_tool.post_message") + tool.kind = "generic" + with self.assertRaises(ValueError): + tool._execute_tool(message="Hello World", record=self.partner) + + def test_post_message_tool_record_different_model(self): + tool = self.env.ref("ai_tool.post_message") + tool.kind = "record" + with self.assertRaises(ValueError): + tool._execute_tool(message="Hello World", record=self.partner) diff --git a/ai_tool/tools.py b/ai_tool/tools.py new file mode 100644 index 00000000..4d43136c --- /dev/null +++ b/ai_tool/tools.py @@ -0,0 +1,18 @@ +from odoo.orm.decorators import attrsetter + + +def aitool(input_schema: dict, output_schema: dict, required_inputs: list = None): + return attrsetter( + "_ai_tool", + { + "input_schema": { + "type": "object", + "properties": input_schema, + "required": required_inputs or [], + }, + "output_schema": { + "type": "object", + "properties": output_schema, + }, + }, + ) diff --git a/ai_tool/views/ai_tool.xml b/ai_tool/views/ai_tool.xml new file mode 100644 index 00000000..56d3cd87 --- /dev/null +++ b/ai_tool/views/ai_tool.xml @@ -0,0 +1,53 @@ + + + + + ai.tool + +
+
+ + + + + + + + + + + + + ai.tool + + + + + + + + + ai.tool + + + + + + + + + AI Tool + ai.tool + list,form + [] + {} + + + + AI Tool + + + + + diff --git a/ai_tool/views/menu.xml b/ai_tool/views/menu.xml new file mode 100644 index 00000000..f897888c --- /dev/null +++ b/ai_tool/views/menu.xml @@ -0,0 +1,6 @@ + + + + + From d9a31b4558622f48e3fd386792afc6830094e175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 13 Aug 2026 17:49:58 +0300 Subject: [PATCH 02/60] [ADD] ai_connection: port module to 19.0 --- ai_connection/README.rst | 92 ++++ ai_connection/__init__.py | 2 + ai_connection/__manifest__.py | 19 + ai_connection/client.py | 19 + ai_connection/i18n/ai_connection.pot | 87 ++++ ai_connection/i18n/it.po | 90 ++++ ai_connection/models/__init__.py | 1 + ai_connection/models/ai_connection.py | 118 ++++++ ai_connection/pyproject.toml | 3 + ai_connection/readme/CONTEXT.md | 3 + ai_connection/readme/CONTRIBUTORS.md | 2 + ai_connection/readme/DESCRIPTION.md | 3 + ai_connection/security/ir.model.access.csv | 3 + ai_connection/static/description/icon.png | Bin 0 -> 9455 bytes ai_connection/static/description/index.html | 441 ++++++++++++++++++++ ai_connection/tests/__init__.py | 1 + ai_connection/tests/fake_models.py | 54 +++ ai_connection/tests/test_connection.py | 78 ++++ ai_connection/views/ai_connection.xml | 54 +++ test-requirements.txt | 1 + 20 files changed, 1071 insertions(+) create mode 100644 ai_connection/README.rst create mode 100644 ai_connection/__init__.py create mode 100644 ai_connection/__manifest__.py create mode 100644 ai_connection/client.py create mode 100644 ai_connection/i18n/ai_connection.pot create mode 100644 ai_connection/i18n/it.po create mode 100644 ai_connection/models/__init__.py create mode 100644 ai_connection/models/ai_connection.py create mode 100644 ai_connection/pyproject.toml create mode 100644 ai_connection/readme/CONTEXT.md create mode 100644 ai_connection/readme/CONTRIBUTORS.md create mode 100644 ai_connection/readme/DESCRIPTION.md create mode 100644 ai_connection/security/ir.model.access.csv create mode 100644 ai_connection/static/description/icon.png create mode 100644 ai_connection/static/description/index.html create mode 100644 ai_connection/tests/__init__.py create mode 100644 ai_connection/tests/fake_models.py create mode 100644 ai_connection/tests/test_connection.py create mode 100644 ai_connection/views/ai_connection.xml create mode 100644 test-requirements.txt 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 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/ai_connection/static/description/index.html b/ai_connection/static/description/index.html new file mode 100644 index 00000000..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/test-requirements.txt b/test-requirements.txt new file mode 100644 index 00000000..881bd9f2 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +freezegun From 780cbc06b28c62a3a96d12537ff36ff018ff6bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 04:47:03 +0300 Subject: [PATCH 03/60] [ADD] ai_document_extraction: scaffold --- ai_document_extraction/__init__.py | 1 + ai_document_extraction/__manifest__.py | 25 +++++++++++++++++++ ai_document_extraction/i18n/.gitkeep | 0 ai_document_extraction/models/__init__.py | 1 + ai_document_extraction/models/account_move.py | 2 ++ .../models/res_config_settings.py | 2 ++ ai_document_extraction/readme/CONTEXT.md | 5 ++++ ai_document_extraction/readme/CONTRIBUTORS.md | 1 + ai_document_extraction/readme/DESCRIPTION.md | 15 +++++++++++ ai_document_extraction/readme/USAGE.md | 11 ++++++++ ai_document_extraction/requirements.txt | 6 +++++ .../security/ir.model.access.csv | 1 + ai_document_extraction/services/__init__.py | 1 + .../services/image_preprocessor.py | 2 ++ .../services/llm_extractor.py | 2 ++ ai_document_extraction/services/ocr_engine.py | 2 ++ ai_document_extraction/tests/__init__.py | 0 .../views/account_move_views.xml | 3 +++ .../views/res_config_settings_views.xml | 3 +++ ai_document_extraction/wizards/__init__.py | 1 + .../wizards/extraction_wizard.py | 2 ++ .../wizards/extraction_wizard_views.xml | 3 +++ 22 files changed, 89 insertions(+) create mode 100644 ai_document_extraction/__init__.py create mode 100644 ai_document_extraction/__manifest__.py create mode 100644 ai_document_extraction/i18n/.gitkeep create mode 100644 ai_document_extraction/models/__init__.py create mode 100644 ai_document_extraction/models/account_move.py create mode 100644 ai_document_extraction/models/res_config_settings.py create mode 100644 ai_document_extraction/readme/CONTEXT.md create mode 100644 ai_document_extraction/readme/CONTRIBUTORS.md create mode 100644 ai_document_extraction/readme/DESCRIPTION.md create mode 100644 ai_document_extraction/readme/USAGE.md create mode 100644 ai_document_extraction/requirements.txt create mode 100644 ai_document_extraction/security/ir.model.access.csv create mode 100644 ai_document_extraction/services/__init__.py create mode 100644 ai_document_extraction/services/image_preprocessor.py create mode 100644 ai_document_extraction/services/llm_extractor.py create mode 100644 ai_document_extraction/services/ocr_engine.py create mode 100644 ai_document_extraction/tests/__init__.py create mode 100644 ai_document_extraction/views/account_move_views.xml create mode 100644 ai_document_extraction/views/res_config_settings_views.xml create mode 100644 ai_document_extraction/wizards/__init__.py create mode 100644 ai_document_extraction/wizards/extraction_wizard.py create mode 100644 ai_document_extraction/wizards/extraction_wizard_views.xml 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..371058e2 --- /dev/null +++ b/ai_document_extraction/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "AI Document Extraction", + "summary": "Extract invoice data from PDFs and images using local OCR and an 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", + "application": False, + "installable": True, + "depends": ["base", "account", "queue_job"], + "external_dependencies": { + "python": ["cv2", "paddleocr", "pdf2image", "rapidfuzz", "requests"], + }, + "data": [ + "security/ir.model.access.csv", + "views/account_move_views.xml", + "views/res_config_settings_views.xml", + "wizards/extraction_wizard_views.xml", + ], + "demo": [], +} diff --git a/ai_document_extraction/i18n/.gitkeep b/ai_document_extraction/i18n/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/ai_document_extraction/models/__init__.py b/ai_document_extraction/models/__init__.py new file mode 100644 index 00000000..123056a4 --- /dev/null +++ b/ai_document_extraction/models/__init__.py @@ -0,0 +1 @@ +from . import account_move, res_config_settings diff --git a/ai_document_extraction/models/account_move.py b/ai_document_extraction/models/account_move.py new file mode 100644 index 00000000..cd7d62e3 --- /dev/null +++ b/ai_document_extraction/models/account_move.py @@ -0,0 +1,2 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). diff --git a/ai_document_extraction/models/res_config_settings.py b/ai_document_extraction/models/res_config_settings.py new file mode 100644 index 00000000..cd7d62e3 --- /dev/null +++ b/ai_document_extraction/models/res_config_settings.py @@ -0,0 +1,2 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). diff --git a/ai_document_extraction/readme/CONTEXT.md b/ai_document_extraction/readme/CONTEXT.md new file mode 100644 index 00000000..c1ad3d86 --- /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 + PaddleOCR), so document +data never leaves the local infrastructure. 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..559b2e37 --- /dev/null +++ b/ai_document_extraction/readme/DESCRIPTION.md @@ -0,0 +1,15 @@ +This module extracts structured invoice data (partner, invoice number, date and +amounts) from uploaded PDF, JPG or PNG documents using a fully local AI pipeline: +OpenCV image preprocessing, PaddleOCR for text + layout detection, and an +OpenAI-compatible LLM (e.g. Ollama running `qwen3:4b`) that converts the OCR text +into 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/USAGE.md b/ai_document_extraction/readme/USAGE.md new file mode 100644 index 00000000..993e630d --- /dev/null +++ b/ai_document_extraction/readme/USAGE.md @@ -0,0 +1,11 @@ +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 under *Settings > Technical > AI Document Extraction* +(API base URL, model name, OCR language, fuzzy match threshold). diff --git a/ai_document_extraction/requirements.txt b/ai_document_extraction/requirements.txt new file mode 100644 index 00000000..72913823 --- /dev/null +++ b/ai_document_extraction/requirements.txt @@ -0,0 +1,6 @@ +paddleocr>=2.7.0,<3.0.0 +paddlepaddle +rapidfuzz +pdf2image +opencv-python-headless +requests 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..97dd8b91 --- /dev/null +++ b/ai_document_extraction/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/ai_document_extraction/services/__init__.py b/ai_document_extraction/services/__init__.py new file mode 100644 index 00000000..a9e09f78 --- /dev/null +++ b/ai_document_extraction/services/__init__.py @@ -0,0 +1 @@ +from . import image_preprocessor, ocr_engine, llm_extractor diff --git a/ai_document_extraction/services/image_preprocessor.py b/ai_document_extraction/services/image_preprocessor.py new file mode 100644 index 00000000..cd7d62e3 --- /dev/null +++ b/ai_document_extraction/services/image_preprocessor.py @@ -0,0 +1,2 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). diff --git a/ai_document_extraction/services/llm_extractor.py b/ai_document_extraction/services/llm_extractor.py new file mode 100644 index 00000000..cd7d62e3 --- /dev/null +++ b/ai_document_extraction/services/llm_extractor.py @@ -0,0 +1,2 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). diff --git a/ai_document_extraction/services/ocr_engine.py b/ai_document_extraction/services/ocr_engine.py new file mode 100644 index 00000000..cd7d62e3 --- /dev/null +++ b/ai_document_extraction/services/ocr_engine.py @@ -0,0 +1,2 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). diff --git a/ai_document_extraction/tests/__init__.py b/ai_document_extraction/tests/__init__.py new file mode 100644 index 00000000..e69de29b 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..6fa84137 --- /dev/null +++ b/ai_document_extraction/views/account_move_views.xml @@ -0,0 +1,3 @@ + + + diff --git a/ai_document_extraction/views/res_config_settings_views.xml b/ai_document_extraction/views/res_config_settings_views.xml new file mode 100644 index 00000000..6fa84137 --- /dev/null +++ b/ai_document_extraction/views/res_config_settings_views.xml @@ -0,0 +1,3 @@ + + + diff --git a/ai_document_extraction/wizards/__init__.py b/ai_document_extraction/wizards/__init__.py new file mode 100644 index 00000000..5dc128b8 --- /dev/null +++ b/ai_document_extraction/wizards/__init__.py @@ -0,0 +1 @@ +from . import extraction_wizard diff --git a/ai_document_extraction/wizards/extraction_wizard.py b/ai_document_extraction/wizards/extraction_wizard.py new file mode 100644 index 00000000..cd7d62e3 --- /dev/null +++ b/ai_document_extraction/wizards/extraction_wizard.py @@ -0,0 +1,2 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). diff --git a/ai_document_extraction/wizards/extraction_wizard_views.xml b/ai_document_extraction/wizards/extraction_wizard_views.xml new file mode 100644 index 00000000..6fa84137 --- /dev/null +++ b/ai_document_extraction/wizards/extraction_wizard_views.xml @@ -0,0 +1,3 @@ + + + From 566a66d2b330286f97187eb4cedaca10a6f8d1a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 04:59:20 +0300 Subject: [PATCH 04/60] [IMP] ai_document_extraction: OCA CI compliance (prettier XML, generated files, external deps) - Drop cv2 from external_dependencies.python (not a valid PyPI name and not in manifestoo EXTERNAL_DEPENDENCIES_MAP; would break OCA CI pip install). Documented in readme/INSTALL.md instead. - Replace per-module requirements.txt with readme/INSTALL.md (19.0 CI generates a repo-level requirements.txt from external_dependencies). - Commit files generated by pre-commit: pyproject.toml (whool), README.rst, static/description/index.html, repo requirements.txt. - Prettier reformat of empty view XMLs; add development_status Alpha. --- ai_document_extraction/README.rst | 154 ++++++ ai_document_extraction/__manifest__.py | 3 +- ai_document_extraction/pyproject.toml | 3 + ai_document_extraction/readme/INSTALL.md | 21 + ai_document_extraction/requirements.txt | 6 - .../static/description/index.html | 505 ++++++++++++++++++ .../views/account_move_views.xml | 5 +- .../views/res_config_settings_views.xml | 5 +- .../wizards/extraction_wizard_views.xml | 5 +- requirements.txt | 5 + 10 files changed, 696 insertions(+), 16 deletions(-) create mode 100644 ai_document_extraction/README.rst create mode 100644 ai_document_extraction/pyproject.toml create mode 100644 ai_document_extraction/readme/INSTALL.md delete mode 100644 ai_document_extraction/requirements.txt create mode 100644 ai_document_extraction/static/description/index.html create mode 100644 requirements.txt diff --git a/ai_document_extraction/README.rst b/ai_document_extraction/README.rst new file mode 100644 index 00000000..ab9af4ad --- /dev/null +++ b/ai_document_extraction/README.rst @@ -0,0 +1,154 @@ +.. 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:3f82d41db1541ac884f7abaecee98200213be81eb8b0fd0ae663df7103dece9b + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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 using a fully +local AI pipeline: OpenCV image preprocessing, PaddleOCR for text + +layout detection, and an OpenAI-compatible LLM (e.g. Ollama running +``qwen3:4b``) that converts the OCR text into 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 + PaddleOCR), so document data never leaves the +local infrastructure. + +Installation +============ + +Copyright 2026 VSL +================== + +License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +============================================================== + +To install and run this module you need the following Python packages +(installed with ``pip``): + +- ``paddleocr>=2.7.0,<3.0.0`` +- ``paddlepaddle`` +- ``pdf2image`` +- ``rapidfuzz`` +- ``opencv-python-headless`` + +And the system packages: + +- ``libgl1`` +- ``libglib2.0-0`` +- ``poppler-utils`` + +A running OpenAI-compatible chat completions endpoint is required, for +example Ollama (``http://ollama:11434/v1``) with a small instruct model +such as ``qwen3:4b``. + +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 under *Settings > Technical > AI Document +Extraction* (API base URL, model name, OCR language, fuzzy match +threshold). + +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/__manifest__.py b/ai_document_extraction/__manifest__.py index 371058e2..3dc3606b 100644 --- a/ai_document_extraction/__manifest__.py +++ b/ai_document_extraction/__manifest__.py @@ -9,11 +9,12 @@ "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", "queue_job"], "external_dependencies": { - "python": ["cv2", "paddleocr", "pdf2image", "rapidfuzz", "requests"], + "python": ["paddleocr", "pdf2image", "rapidfuzz", "requests"], }, "data": [ "security/ir.model.access.csv", 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/INSTALL.md b/ai_document_extraction/readme/INSTALL.md new file mode 100644 index 00000000..a19bc782 --- /dev/null +++ b/ai_document_extraction/readme/INSTALL.md @@ -0,0 +1,21 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +To install and run this module you need the following Python packages +(installed with ``pip``): + +* `paddleocr>=2.7.0,<3.0.0` +* `paddlepaddle` +* `pdf2image` +* `rapidfuzz` +* `opencv-python-headless` + +And the system packages: + +* `libgl1` +* `libglib2.0-0` +* `poppler-utils` + +A running OpenAI-compatible chat completions endpoint is required, for example +Ollama (`http://ollama:11434/v1`) with a small instruct model such as +`qwen3:4b`. diff --git a/ai_document_extraction/requirements.txt b/ai_document_extraction/requirements.txt deleted file mode 100644 index 72913823..00000000 --- a/ai_document_extraction/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -paddleocr>=2.7.0,<3.0.0 -paddlepaddle -rapidfuzz -pdf2image -opencv-python-headless -requests diff --git a/ai_document_extraction/static/description/index.html b/ai_document_extraction/static/description/index.html new file mode 100644 index 00000000..e65c87ab --- /dev/null +++ b/ai_document_extraction/static/description/index.html @@ -0,0 +1,505 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

AI Document Extraction

+ +

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

+

This module extracts structured invoice data (partner, invoice number, +date and amounts) from uploaded PDF, JPG or PNG documents using a fully +local AI pipeline: OpenCV image preprocessing, PaddleOCR for text + +layout detection, and an OpenAI-compatible LLM (e.g. Ollama running +qwen3:4b) that converts the OCR text into 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 + PaddleOCR), so document data never leaves the +local infrastructure.

+
+ + +
+

License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

+

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

+
    +
  • paddleocr>=2.7.0,<3.0.0
  • +
  • paddlepaddle
  • +
  • pdf2image
  • +
  • rapidfuzz
  • +
  • opencv-python-headless
  • +
+

And the system packages:

+
    +
  • libgl1
  • +
  • libglib2.0-0
  • +
  • poppler-utils
  • +
+

A running OpenAI-compatible chat completions endpoint is required, for +example Ollama (http://ollama:11434/v1) with a small instruct model +such as qwen3:4b.

+
+
+

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 under Settings > Technical > AI Document +Extraction (API base URL, model name, OCR language, fuzzy match +threshold).

+
+
+

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/views/account_move_views.xml b/ai_document_extraction/views/account_move_views.xml index 6fa84137..e0063c23 100644 --- a/ai_document_extraction/views/account_move_views.xml +++ b/ai_document_extraction/views/account_move_views.xml @@ -1,3 +1,2 @@ - - - + + diff --git a/ai_document_extraction/views/res_config_settings_views.xml b/ai_document_extraction/views/res_config_settings_views.xml index 6fa84137..e0063c23 100644 --- a/ai_document_extraction/views/res_config_settings_views.xml +++ b/ai_document_extraction/views/res_config_settings_views.xml @@ -1,3 +1,2 @@ - - - + + diff --git a/ai_document_extraction/wizards/extraction_wizard_views.xml b/ai_document_extraction/wizards/extraction_wizard_views.xml index 6fa84137..e0063c23 100644 --- a/ai_document_extraction/wizards/extraction_wizard_views.xml +++ b/ai_document_extraction/wizards/extraction_wizard_views.xml @@ -1,3 +1,2 @@ - - - + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..6853d6b3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +# generated from manifests external_dependencies +paddleocr +pdf2image +rapidfuzz +requests From ddb6fad6c071b66c0b6152b6d1e5e6c9634a9c53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 05:05:19 +0300 Subject: [PATCH 05/60] [IMP] ai_document_extraction: settings model and view --- .../models/res_config_settings.py | 37 +++++++++++ .../views/res_config_settings_views.xml | 65 ++++++++++++++++++- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/ai_document_extraction/models/res_config_settings.py b/ai_document_extraction/models/res_config_settings.py index cd7d62e3..0cd2f874 100644 --- a/ai_document_extraction/models/res_config_settings.py +++ b/ai_document_extraction/models/res_config_settings.py @@ -1,2 +1,39 @@ # Copyright 2026 VSL # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + ai_api_base_url = fields.Char( + string="AI API Base URL", + default="http://ollama:11434/v1", + config_parameter="ai_document_extraction.api_base_url", + ) + ai_api_key = fields.Char( + string="AI API Key", + default="dummy", + config_parameter="ai_document_extraction.api_key", + ) + ai_model_name = fields.Char( + string="AI Model Name", + default="qwen3:4b", + config_parameter="ai_document_extraction.model_name", + ) + ocr_language = fields.Selection( + [ + ("tur+eng", "Turkish + English"), + ("tur", "Turkish"), + ("eng", "English"), + ], + string="OCR Language", + default="tur+eng", + config_parameter="ai_document_extraction.ocr_language", + ) + fuzzy_match_threshold = fields.Integer( + string="Partner Match Threshold", + default=85, + config_parameter="ai_document_extraction.fuzzy_match_threshold", + ) diff --git a/ai_document_extraction/views/res_config_settings_views.xml b/ai_document_extraction/views/res_config_settings_views.xml index e0063c23..10ed3886 100644 --- a/ai_document_extraction/views/res_config_settings_views.xml +++ b/ai_document_extraction/views/res_config_settings_views.xml @@ -1,2 +1,65 @@ - + + + res.config.settings.view.form.ai.extraction + res.config.settings + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 99a493c503fda83cee2b277c98265adcc3f44081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 05:12:19 +0300 Subject: [PATCH 06/60] [IMP] ai_document_extraction: refine settings (setting id, threshold help, USAGE wording) --- ai_document_extraction/README.rst | 6 +++--- ai_document_extraction/models/res_config_settings.py | 2 ++ ai_document_extraction/readme/USAGE.md | 4 ++-- ai_document_extraction/static/description/index.html | 6 +++--- ai_document_extraction/views/res_config_settings_views.xml | 1 + 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/ai_document_extraction/README.rst b/ai_document_extraction/README.rst index ab9af4ad..5332d335 100644 --- a/ai_document_extraction/README.rst +++ b/ai_document_extraction/README.rst @@ -109,9 +109,9 @@ Usage 5. If the partner could not be matched, click **Review Extraction** and pick or create the partner in the wizard. -Configure the AI backend under *Settings > Technical > AI Document -Extraction* (API base URL, model name, OCR language, fuzzy match -threshold). +Configure the AI backend under *Settings > General Settings > AI +Document Extraction* (API base URL, model name, OCR language, fuzzy +match threshold). Bug Tracker =========== diff --git a/ai_document_extraction/models/res_config_settings.py b/ai_document_extraction/models/res_config_settings.py index 0cd2f874..2b23a9df 100644 --- a/ai_document_extraction/models/res_config_settings.py +++ b/ai_document_extraction/models/res_config_settings.py @@ -35,5 +35,7 @@ class ResConfigSettings(models.TransientModel): fuzzy_match_threshold = fields.Integer( string="Partner Match Threshold", default=85, + help="Minimum similarity percentage (0-100) required to auto-match the " + "extracted partner name with an existing partner.", config_parameter="ai_document_extraction.fuzzy_match_threshold", ) diff --git a/ai_document_extraction/readme/USAGE.md b/ai_document_extraction/readme/USAGE.md index 993e630d..cfb737ac 100644 --- a/ai_document_extraction/readme/USAGE.md +++ b/ai_document_extraction/readme/USAGE.md @@ -7,5 +7,5 @@ 5. If the partner could not be matched, click **Review Extraction** and pick or create the partner in the wizard. -Configure the AI backend under *Settings > Technical > AI Document Extraction* -(API base URL, model name, OCR language, fuzzy match threshold). +Configure the AI backend under *Settings > General Settings > AI Document +Extraction* (API base URL, model name, OCR language, fuzzy match threshold). diff --git a/ai_document_extraction/static/description/index.html b/ai_document_extraction/static/description/index.html index e65c87ab..651d6c11 100644 --- a/ai_document_extraction/static/description/index.html +++ b/ai_document_extraction/static/description/index.html @@ -460,9 +460,9 @@

Usage

  • If the partner could not be matched, click Review Extraction and pick or create the partner in the wizard.
  • -

    Configure the AI backend under Settings > Technical > AI Document -Extraction (API base URL, model name, OCR language, fuzzy match -threshold).

    +

    Configure the AI backend under Settings > General Settings > AI +Document Extraction (API base URL, model name, OCR language, fuzzy +match threshold).

    Bug Tracker

    diff --git a/ai_document_extraction/views/res_config_settings_views.xml b/ai_document_extraction/views/res_config_settings_views.xml index 10ed3886..c1b3da57 100644 --- a/ai_document_extraction/views/res_config_settings_views.xml +++ b/ai_document_extraction/views/res_config_settings_views.xml @@ -8,6 +8,7 @@ From 7ddcc87493edbe9ca9766e879ff31d37fe034d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 05:21:51 +0300 Subject: [PATCH 07/60] [ADD] ai_document_extraction: image preprocessor service --- .../services/image_preprocessor.py | 47 +++++++++++++++++++ ai_document_extraction/tests/__init__.py | 1 + .../tests/test_extraction.py | 33 +++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 ai_document_extraction/tests/test_extraction.py diff --git a/ai_document_extraction/services/image_preprocessor.py b/ai_document_extraction/services/image_preprocessor.py index cd7d62e3..31fe52bb 100644 --- a/ai_document_extraction/services/image_preprocessor.py +++ b/ai_document_extraction/services/image_preprocessor.py @@ -1,2 +1,49 @@ # Copyright 2026 VSL # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import os +import tempfile + +MAX_DIM = 2000 + + +def preprocess_image(image_path): + """Enhance and resize an image for OCR. + + Converts to grayscale, applies CLAHE contrast enhancement, denoises with a + Gaussian blur, binarizes with an Otsu threshold and resizes down (keeping + aspect ratio) if the longest side exceeds ``MAX_DIM``. + + Returns the path to the processed PNG. The caller must delete it. + """ + # Limit OpenMP/OpenCV to a single thread. OpenCV's parallel thread pool + # crashes in forked worker processes (e.g. the Odoo test runner) where the + # thread pool state is inherited from the parent, and in containerized + # environments with restricted thread limits. The environment variable must + # be set before ``import cv2`` (the lazy import below) so the native OpenMP + # runtime picks it up. + os.environ["OMP_NUM_THREADS"] = "1" + import cv2 + + cv2.setNumThreads(1) + + img = cv2.imread(image_path) + if img is None: + raise ValueError("Could not read image: %s" % image_path) + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + enhanced = clahe.apply(gray) + blurred = cv2.GaussianBlur(enhanced, (5, 5), 0) + _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + height, width = binary.shape + if max(width, height) > MAX_DIM: + scale = MAX_DIM / float(max(width, height)) + binary = cv2.resize( + binary, + (int(width * scale), int(height * scale)), + interpolation=cv2.INTER_AREA, + ) + handle, out_path = tempfile.mkstemp(suffix=".png") + os.close(handle) + cv2.imwrite(out_path, binary) + return out_path diff --git a/ai_document_extraction/tests/__init__.py b/ai_document_extraction/tests/__init__.py index e69de29b..3b9649cc 100644 --- a/ai_document_extraction/tests/__init__.py +++ 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..404ae845 --- /dev/null +++ b/ai_document_extraction/tests/test_extraction.py @@ -0,0 +1,33 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import os + +from odoo.tests import TransactionCase + + +class TestImagePreprocessor(TransactionCase): + def _sample_image(self): + import base64 + + png = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8B" + "QDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ) + path = "/tmp/test_sample.png" + with open(path, "wb") as handle: + handle.write(base64.b64decode(png)) + return path + + def test_preprocess_returns_file(self): + from odoo.addons.ai_document_extraction.services.image_preprocessor import ( + preprocess_image, + ) + + source = self._sample_image() + result = preprocess_image(source) + try: + self.assertTrue(os.path.exists(result)) + self.assertTrue(result.endswith(".png")) + finally: + os.unlink(result) From 1e6b823f85897935809cd1ac1f6ae8f0340e74ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 05:22:12 +0300 Subject: [PATCH 08/60] [FIX] ai_document_extraction: ruff UP031 format specifier --- ai_document_extraction/services/image_preprocessor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai_document_extraction/services/image_preprocessor.py b/ai_document_extraction/services/image_preprocessor.py index 31fe52bb..a54c7d88 100644 --- a/ai_document_extraction/services/image_preprocessor.py +++ b/ai_document_extraction/services/image_preprocessor.py @@ -29,7 +29,7 @@ def preprocess_image(image_path): img = cv2.imread(image_path) if img is None: - raise ValueError("Could not read image: %s" % image_path) + raise ValueError(f"Could not read image: {image_path}") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) enhanced = clahe.apply(gray) From 17018c229212ee0c9d734a2860f5379b838d3f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 05:22:37 +0300 Subject: [PATCH 09/60] [FIX] ai_document_extraction: use relative import in tests (pylint W8150) --- ai_document_extraction/tests/test_extraction.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ai_document_extraction/tests/test_extraction.py b/ai_document_extraction/tests/test_extraction.py index 404ae845..f4a71763 100644 --- a/ai_document_extraction/tests/test_extraction.py +++ b/ai_document_extraction/tests/test_extraction.py @@ -20,9 +20,7 @@ def _sample_image(self): return path def test_preprocess_returns_file(self): - from odoo.addons.ai_document_extraction.services.image_preprocessor import ( - preprocess_image, - ) + from ..services.image_preprocessor import preprocess_image source = self._sample_image() result = preprocess_image(source) From 763265bc817b6cbb28858fa57ebbba6e08b25d80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 05:26:49 +0300 Subject: [PATCH 10/60] [IMP] ai_document_extraction: harden image preprocessor (imwrite check, accurate comment) --- .../services/image_preprocessor.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ai_document_extraction/services/image_preprocessor.py b/ai_document_extraction/services/image_preprocessor.py index a54c7d88..ba119bbb 100644 --- a/ai_document_extraction/services/image_preprocessor.py +++ b/ai_document_extraction/services/image_preprocessor.py @@ -16,12 +16,11 @@ def preprocess_image(image_path): Returns the path to the processed PNG. The caller must delete it. """ - # Limit OpenMP/OpenCV to a single thread. OpenCV's parallel thread pool - # crashes in forked worker processes (e.g. the Odoo test runner) where the - # thread pool state is inherited from the parent, and in containerized - # environments with restricted thread limits. The environment variable must - # be set before ``import cv2`` (the lazy import below) so the native OpenMP - # runtime picks it up. + # Limit the OpenMP runtime to a single thread before OpenCV is imported. + # In forked worker processes (e.g. the Odoo test runner) the inherited + # OpenMP thread-pool state crashes at import time with a SIGSEGV; the env + # variable must be set before ``import cv2`` and cv2.setNumThreads(1) alone + # does NOT prevent it. os.environ["OMP_NUM_THREADS"] = "1" import cv2 @@ -45,5 +44,7 @@ def preprocess_image(image_path): ) handle, out_path = tempfile.mkstemp(suffix=".png") os.close(handle) - cv2.imwrite(out_path, binary) + if not cv2.imwrite(out_path, binary): + os.unlink(out_path) + raise ValueError(f"Could not write processed image: {out_path}") return out_path From 0a27e746b27f6c19b00f25945f10695ea5412b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 05:28:01 +0300 Subject: [PATCH 11/60] [ADD] ai_document_extraction: OCR engine service with layout tags --- ai_document_extraction/services/ocr_engine.py | 62 +++++++++++++++++++ .../tests/test_extraction.py | 32 ++++++++++ 2 files changed, 94 insertions(+) diff --git a/ai_document_extraction/services/ocr_engine.py b/ai_document_extraction/services/ocr_engine.py index cd7d62e3..174094dd 100644 --- a/ai_document_extraction/services/ocr_engine.py +++ b/ai_document_extraction/services/ocr_engine.py @@ -1,2 +1,64 @@ # Copyright 2026 VSL # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import threading + +_PADDLE_LANG_MAP = { + "tur+eng": "latin", + "tur": "latin", + "eng": "en", +} + +_thread_local = threading.local() + + +def _get_ocr(ocr_language): + """Create (once per thread) a PaddleOCR instance for the given language.""" + import paddleocr + + lang = _PADDLE_LANG_MAP.get(ocr_language, "latin") + ocr = getattr(_thread_local, "ocr", None) + ocr_lang = getattr(_thread_local, "ocr_lang", None) + if ocr is None or ocr_lang != lang: + _thread_local.ocr = paddleocr.PaddleOCR(lang=lang, use_angle_cls=True) + _thread_local.ocr_lang = lang + return _thread_local.ocr + + +def extract_text_with_layout(image_path, ocr_language="tur+eng", image_height=None): + """Run OCR and tag each line with a positional [HEADER]/[BODY]/[FOOTER]. + + The top 20% of the page is tagged [HEADER], the bottom 20% [FOOTER] and + everything in between [BODY], based on the vertical center of each text + line. This lets the LLM ignore logo/slogan texts found in the header. + + Returns one "[TAG] text" line per OCR line, joined by newlines. + """ + import cv2 + + if image_height is None: + img = cv2.imread(image_path) + if img is None: + raise ValueError(f"Could not read image: {image_path}") + image_height = img.shape[0] + ocr = _get_ocr(ocr_language) + result = ocr.ocr(image_path, cls=True) + lines = [] + if not result: + return "" + for page in result: + if not page: + continue + for box, (text, _score) in page: + ys = [point[1] for point in box] + center_y = sum(ys) / len(ys) + ratio = center_y / float(image_height) + if ratio < 0.2: + tag = "[HEADER]" + elif ratio > 0.8: + tag = "[FOOTER]" + else: + tag = "[BODY]" + if text and text.strip(): + lines.append(f"{tag} {text.strip()}") + return "\n".join(lines) diff --git a/ai_document_extraction/tests/test_extraction.py b/ai_document_extraction/tests/test_extraction.py index f4a71763..28b5734c 100644 --- a/ai_document_extraction/tests/test_extraction.py +++ b/ai_document_extraction/tests/test_extraction.py @@ -29,3 +29,35 @@ def test_preprocess_returns_file(self): self.assertTrue(result.endswith(".png")) finally: os.unlink(result) + + +class TestOcrEngine(TransactionCase): + def test_layout_tags(self): + from unittest import mock + + from ..services import ocr_engine + + def fake_ocr(image_path, cls=True): + return [ + [ + ([(0, 10), (100, 10), (100, 30), (0, 30)], ("voslo", 0.99)), + ( + [(0, 300), (100, 300), (100, 320), (0, 320)], + ("Invoice No: 123", 0.99), + ), + ( + [(0, 650), (100, 650), (100, 670), (0, 670)], + ("page 1 of 1", 0.99), + ), + ] + ] + + with mock.patch.object( + ocr_engine, "_get_ocr", return_value=mock.Mock(ocr=fake_ocr) + ): + result = ocr_engine.extract_text_with_layout( + "/tmp/fake.png", image_height=700 + ) + self.assertIn("[HEADER] voslo", result) + self.assertIn("[BODY] Invoice No: 123", result) + self.assertIn("[FOOTER] page 1 of 1", result) From 000876667ec4ba326edfe8eb9a7316d2ef800e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 05:31:12 +0300 Subject: [PATCH 12/60] [IMP] ai_document_extraction: cache test + height guard for OCR engine --- ai_document_extraction/services/ocr_engine.py | 2 ++ .../tests/test_extraction.py | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/ai_document_extraction/services/ocr_engine.py b/ai_document_extraction/services/ocr_engine.py index 174094dd..6137ab18 100644 --- a/ai_document_extraction/services/ocr_engine.py +++ b/ai_document_extraction/services/ocr_engine.py @@ -41,6 +41,8 @@ def extract_text_with_layout(image_path, ocr_language="tur+eng", image_height=No if img is None: raise ValueError(f"Could not read image: {image_path}") image_height = img.shape[0] + if image_height <= 0: + raise ValueError(f"Invalid image height: {image_height}") ocr = _get_ocr(ocr_language) result = ocr.ocr(image_path, cls=True) lines = [] diff --git a/ai_document_extraction/tests/test_extraction.py b/ai_document_extraction/tests/test_extraction.py index 28b5734c..3972326a 100644 --- a/ai_document_extraction/tests/test_extraction.py +++ b/ai_document_extraction/tests/test_extraction.py @@ -61,3 +61,26 @@ def fake_ocr(image_path, cls=True): self.assertIn("[HEADER] voslo", result) self.assertIn("[BODY] Invoice No: 123", result) self.assertIn("[FOOTER] page 1 of 1", result) + + def test_get_ocr_caches_instance_per_language(self): + import sys + from unittest import mock + + from ..services import ocr_engine + + class FakePaddle: + def __init__(self, **kwargs): + self.kwargs = kwargs + + fake_module = mock.Mock() + fake_module.PaddleOCR = FakePaddle + with mock.patch.dict(sys.modules, {"paddleocr": fake_module}): + ocr_engine._thread_local.ocr = None + ocr_engine._thread_local.ocr_lang = None + first = ocr_engine._get_ocr("tur+eng") + second = ocr_engine._get_ocr("tur+eng") + self.assertIs(first, second) + self.assertEqual(first.kwargs["lang"], "latin") + other = ocr_engine._get_ocr("eng") + self.assertIsNot(first, other) + self.assertEqual(other.kwargs["lang"], "en") From 937c0bb5bba8b3f3dd169b6f898dce77261abbec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 05:32:31 +0300 Subject: [PATCH 13/60] [ADD] ai_document_extraction: LLM extractor service --- .../services/llm_extractor.py | 72 +++++++++++++++++++ .../tests/test_extraction.py | 28 ++++++++ 2 files changed, 100 insertions(+) diff --git a/ai_document_extraction/services/llm_extractor.py b/ai_document_extraction/services/llm_extractor.py index cd7d62e3..a1e5965a 100644 --- a/ai_document_extraction/services/llm_extractor.py +++ b/ai_document_extraction/services/llm_extractor.py @@ -1,2 +1,74 @@ # Copyright 2026 VSL # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import re + +import requests + +SYSTEM_PROMPT = ( + "You are a strict invoice data extraction assistant. You will receive OCR " + "text tagged with positional layouts ([HEADER], [BODY], [FOOTER]). Short texts " + "in [HEADER] are often logos or slogans (e.g. 'voslo') and MUST NOT be used as " + "the partner_name unless explicitly stated as the issuer. Extract real invoice " + "data only. Do not calculate missing values; output null if unknown. Respond " + "ONLY with a valid JSON object." +) + +EXPECTED_FIELDS = ( + "partner_name", + "invoice_number", + "invoice_date", + "amount_untaxed", + "amount_tax", + "amount_total", + "currency", +) + + +def _build_user_prompt(processed_text): + return ( + "/no_think\n" + "Extract the following fields from the OCR text as a single JSON object:\n" + '{"partner_name": , "invoice_number": , ' + '"invoice_date": <"YYYY-MM-DD" or null>, "amount_untaxed": , ' + '"amount_tax": , "amount_total": , ' + '"currency": }\n' + "Output ONLY the JSON object, with no markdown or extra text.\n\n" + f"OCR text:\n{processed_text}" + ) + + +def _parse_json_response(content): + match = re.search(r"\{.*\}", content, re.DOTALL) + if not match: + raise ValueError(f"No JSON object found in LLM response: {content[:200]}") + data = json.loads(match.group(0)) + if not isinstance(data, dict): + raise ValueError("LLM response is not a JSON object") + for field in EXPECTED_FIELDS: + data.setdefault(field, None) + return data + + +def extract_invoice_data( + processed_text, api_base_url, api_model_name, api_key="dummy", timeout=120 +): + """Call an OpenAI-compatible /chat/completions endpoint and return the dict.""" + url = f"{api_base_url.rstrip('/')}/chat/completions" + payload = { + "model": api_model_name, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": _build_user_prompt(processed_text)}, + ], + "temperature": 0, + "stream": False, + } + headers = {} + if api_key and api_key != "dummy": + headers["Authorization"] = f"Bearer {api_key}" + response = requests.post(url, json=payload, headers=headers, timeout=timeout) + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"] + return _parse_json_response(content) diff --git a/ai_document_extraction/tests/test_extraction.py b/ai_document_extraction/tests/test_extraction.py index 3972326a..b61ef669 100644 --- a/ai_document_extraction/tests/test_extraction.py +++ b/ai_document_extraction/tests/test_extraction.py @@ -84,3 +84,31 @@ def __init__(self, **kwargs): other = ocr_engine._get_ocr("eng") self.assertIsNot(first, other) self.assertEqual(other.kwargs["lang"], "en") + + +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_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.") From 758d04cdbdd1c03b8c3aae9d6c913eac16a6d39b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 05:34:50 +0300 Subject: [PATCH 14/60] [IMP] ai_document_extraction: robust JSON parsing and LLM client tests - _parse_json_response uses raw_decode to find the first valid JSON object, ignoring trailing prose with extra braces and markdown code fences. - Add tests for extract_invoice_data (mocked requests) covering the Authorization header behavior. --- .../services/llm_extractor.py | 25 ++++--- .../tests/test_extraction.py | 72 +++++++++++++++++++ 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/ai_document_extraction/services/llm_extractor.py b/ai_document_extraction/services/llm_extractor.py index a1e5965a..4237e5c8 100644 --- a/ai_document_extraction/services/llm_extractor.py +++ b/ai_document_extraction/services/llm_extractor.py @@ -2,7 +2,6 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). import json -import re import requests @@ -40,15 +39,21 @@ def _build_user_prompt(processed_text): def _parse_json_response(content): - match = re.search(r"\{.*\}", content, re.DOTALL) - if not match: - raise ValueError(f"No JSON object found in LLM response: {content[:200]}") - data = json.loads(match.group(0)) - if not isinstance(data, dict): - raise ValueError("LLM response is not a JSON object") - for field in EXPECTED_FIELDS: - data.setdefault(field, None) - return data + 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) + return data + raise ValueError(f"No JSON object found in LLM response: {content[:200]}") def extract_invoice_data( diff --git a/ai_document_extraction/tests/test_extraction.py b/ai_document_extraction/tests/test_extraction.py index b61ef669..38ed6728 100644 --- a/ai_document_extraction/tests/test_extraction.py +++ b/ai_document_extraction/tests/test_extraction.py @@ -112,3 +112,75 @@ def test_parse_json_raises_without_object(self): 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_extract_invoice_data_posts_and_parses(self): + from unittest import mock + + from ..services import llm_extractor + + response = mock.Mock() + response.status_code = 200 + response.json.return_value = { + "choices": [ + { + "message": { + "content": '{"partner_name": "Voslo", "amount_total": 118.0}' + } + } + ] + } + with mock.patch.object( + llm_extractor.requests, "post", return_value=response + ) as post_mock: + data = llm_extractor.extract_invoice_data( + "[BODY] Invoice No: 1", + "http://ollama:11434/v1", + "qwen3:4b", + ) + self.assertEqual(data["partner_name"], "Voslo") + post_mock.assert_called_once() + payload = post_mock.call_args.kwargs["json"] + self.assertEqual(payload["model"], "qwen3:4b") + self.assertEqual(payload["temperature"], 0) + self.assertNotIn("Authorization", post_mock.call_args.kwargs["headers"]) + + def test_extract_invoice_data_sends_api_key(self): + from unittest import mock + + from ..services import llm_extractor + + response = mock.Mock() + response.status_code = 200 + response.json.return_value = { + "choices": [{"message": {"content": '{"invoice_number": "X"}'}}] + } + with mock.patch.object( + llm_extractor.requests, "post", return_value=response + ) as post_mock: + llm_extractor.extract_invoice_data( + "text", "http://host:11434/v1", "m", api_key="secret" + ) + self.assertEqual( + post_mock.call_args.kwargs["headers"]["Authorization"], + "Bearer secret", + ) From a98fdbe4d794cc4b62574edd68cabe7db4d4f781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 05:50:32 +0300 Subject: [PATCH 15/60] [ADD] ai_document_extraction: account.move integration and views --- ai_document_extraction/models/account_move.py | 271 ++++++++++++++++++ .../tests/test_extraction.py | 127 ++++++++ .../views/account_move_views.xml | 49 +++- 3 files changed, 446 insertions(+), 1 deletion(-) diff --git a/ai_document_extraction/models/account_move.py b/ai_document_extraction/models/account_move.py index cd7d62e3..306bd91e 100644 --- a/ai_document_extraction/models/account_move.py +++ b/ai_document_extraction/models/account_move.py @@ -1,2 +1,273 @@ # Copyright 2026 VSL # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging +import os +import tempfile + +from odoo import api, fields, models +from odoo.exceptions import UserError + +from ..services import image_preprocessor, llm_extractor, ocr_engine + +_logger = logging.getLogger(__name__) + +_IMAGE_EXTENSIONS = ("pdf", "png", "jpg", "jpeg", "gif", "bmp") +_DEFAULT_LINE_NAME = "AI extracted amount" + + +class AccountMove(models.Model): + _inherit = "account.move" + + ai_extraction_state = fields.Selection( + [ + ("draft", "Not processed"), + ("processing", "Processing"), + ("done", "Done"), + ("error", "Error"), + ], + string="AI Extraction State", + default="draft", + copy=False, + ) + ai_raw_extraction = fields.Text( + string="AI Raw Extraction", + copy=False, + readonly=True, + ) + ai_extracted_tax = fields.Float( + string="Extracted Tax Amount", + copy=False, + readonly=True, + ) + ai_extracted_total = fields.Float( + string="Extracted Total Amount", + copy=False, + readonly=True, + ) + + @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 { + "api_base_url": self._ai_get_param( + "api_base_url", "http://ollama:11434/v1" + ), + "api_key": self._ai_get_param("api_key", "dummy"), + "model_name": self._ai_get_param("model_name", "qwen3:4b"), + "ocr_language": self._ai_get_param("ocr_language", "tur+eng"), + "fuzzy_match_threshold": int( + self._ai_get_param("fuzzy_match_threshold", "85") + ), + } + + 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: + name = attachment.name or "" + if attachment.mimetype and attachment.mimetype.split("/")[-1] in ( + _IMAGE_EXTENSIONS + ): + return attachment + if name.rsplit(".", 1)[-1].lower() in _IMAGE_EXTENSIONS: + return attachment + return None + + 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"): + raise UserError( + self.env._("AI extraction is only available on vendor bills.") + ) + 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.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 "", + } + ) + 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_prepare_image(self, attachment): + data = attachment.with_context(bin_size=False).raw + extension = (attachment.name or "file").rsplit(".", 1)[-1].lower() + handle, file_path = tempfile.mkstemp(suffix=f".{extension}") + os.close(handle) + 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=300, 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) + return png_path + return file_path + + 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 _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_set_untaxed_line(self, untaxed): + self.ensure_one() + account = self.env["account.account"].search( + [("internal_group", "=", "expense")], limit=1 + ) + if not account: + account = self.env["account.account"].search([], limit=1) + stale = self.line_ids.filtered(lambda line: line.name == _DEFAULT_LINE_NAME) + commands = [(2, line.id) for line in stale] + commands.append( + ( + 0, + 0, + { + "name": _DEFAULT_LINE_NAME, + "account_id": account.id, + "quantity": 1, + "price_unit": untaxed, + }, + ) + ) + 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, + ) + if data.get("invoice_number"): + values["ref"] = data["invoice_number"] + 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 + if values: + self.write(values) + untaxed = data.get("amount_untaxed") + if isinstance(untaxed, (int, float)) and untaxed > 0: + self._ai_set_untaxed_line(untaxed) + self.ai_extracted_tax = data.get("amount_tax") or 0.0 + self.ai_extracted_total = 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) + file_path = None + processed_path = None + try: + file_path = self._ai_prepare_image(attachment) + processed_path = image_preprocessor.preprocess_image(file_path) + settings = self._ai_settings() + ocr_text = ocr_engine.extract_text_with_layout( + processed_path, settings["ocr_language"] + ) + if not ocr_text.strip(): + raise UserError(self.env._("No text was detected in the document.")) + data = llm_extractor.extract_invoice_data( + ocr_text, + settings["api_base_url"], + settings["model_name"], + settings["api_key"], + ) + 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 processed_path: + self._ai_cleanup_tmp(processed_path) + if file_path: + self._ai_cleanup_tmp(file_path) diff --git a/ai_document_extraction/tests/test_extraction.py b/ai_document_extraction/tests/test_extraction.py index 38ed6728..fd855a9d 100644 --- a/ai_document_extraction/tests/test_extraction.py +++ b/ai_document_extraction/tests/test_extraction.py @@ -184,3 +184,130 @@ def test_extract_invoice_data_sends_api_key(self): post_mock.call_args.kwargs["headers"]["Authorization"], "Bearer secret", ) + + +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, + } + ) + + 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_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_job_happy_path(self): + from unittest import mock + + from ..services import image_preprocessor, llm_extractor, ocr_engine + + attachment = self._attach() + with ( + mock.patch.object( + image_preprocessor, + "preprocess_image", + return_value="/tmp/pp.png", + ), + mock.patch.object( + ocr_engine, + "extract_text_with_layout", + return_value="[BODY] Voslo Lojistik\n[BODY] Invoice No: FT-123", + ), + mock.patch.object( + llm_extractor, + "extract_invoice_data", + return_value={ + "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", + }, + ), + ): + 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 unittest import mock + + from ..services import image_preprocessor, llm_extractor, ocr_engine + + attachment = self._attach() + with ( + mock.patch.object( + image_preprocessor, + "preprocess_image", + return_value="/tmp/pp.png", + ), + mock.patch.object( + ocr_engine, + "extract_text_with_layout", + return_value="[BODY] x", + ), + mock.patch.object( + llm_extractor, + "extract_invoice_data", + side_effect=RuntimeError("boom"), + ), + ): + self.move._extract_with_ai_job(attachment.id) + self.assertEqual(self.move.ai_extraction_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") diff --git a/ai_document_extraction/views/account_move_views.xml b/ai_document_extraction/views/account_move_views.xml index e0063c23..743a4aca 100644 --- a/ai_document_extraction/views/account_move_views.xml +++ b/ai_document_extraction/views/account_move_views.xml @@ -1,2 +1,49 @@ - + + + account.move.form.ai.extraction + account.move + + + +
    - -
    -

    License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

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

      @@ -448,7 +440,7 @@

      License AGPL-3.0 or later (qwen3:4b.

    -

    Usage

    +

    Usage

    1. Go to Accounting > Vendors > Bills and create a draft vendor bill (or open an existing draft one).
    2. @@ -465,7 +457,7 @@

      Usage

      match threshold).

    -

    Bug Tracker

    +

    Bug Tracker

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

    Bug Tracker

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

    -

    Credits

    +

    Credits

    -

    Maintainers

    +

    Maintainers

    This module is maintained by the OCA.

    Odoo Community Association diff --git a/checklog-odoo.cfg b/checklog-odoo.cfg index 58d43aa6..5f79f5cf 100644 --- a/checklog-odoo.cfg +++ b/checklog-odoo.cfg @@ -3,3 +3,4 @@ ignore= WARNING.* 0 failed, 0 error\(s\).* WARNING .* Killing chrome descendants-or-self .* WARNING.* Missing widget: res_partner_many2one for field of type many2one.* + ERROR.*AI extraction failed for move.* From 6ac885eb0ab7702379b1c9225b5ce6e340c295f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 06:31:28 +0300 Subject: [PATCH 21/60] [FIX] ai_document_extraction: skip cv2 test in CI without libGL OCA CI images lack libGL, so importing cv2 (pulled transitively by paddleocr) fails. Detect availability with find_spec and skip the pre-processor test; ocr_engine imports cv2 only when image_height is not provided. --- ai_document_extraction/services/ocr_engine.py | 4 ++-- ai_document_extraction/tests/test_extraction.py | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/ai_document_extraction/services/ocr_engine.py b/ai_document_extraction/services/ocr_engine.py index 6137ab18..181a7f1f 100644 --- a/ai_document_extraction/services/ocr_engine.py +++ b/ai_document_extraction/services/ocr_engine.py @@ -34,9 +34,9 @@ def extract_text_with_layout(image_path, ocr_language="tur+eng", image_height=No Returns one "[TAG] text" line per OCR line, joined by newlines. """ - import cv2 - if image_height is None: + import cv2 + img = cv2.imread(image_path) if img is None: raise ValueError(f"Could not read image: {image_path}") diff --git a/ai_document_extraction/tests/test_extraction.py b/ai_document_extraction/tests/test_extraction.py index 5e897507..e04ce015 100644 --- a/ai_document_extraction/tests/test_extraction.py +++ b/ai_document_extraction/tests/test_extraction.py @@ -1,11 +1,16 @@ # Copyright 2026 VSL # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import importlib.util import os +from unittest import skipUnless from odoo.tests import TransactionCase +_HAVE_CV2 = importlib.util.find_spec("cv2") is not None + +@skipUnless(_HAVE_CV2, "OpenCV (cv2) not available") class TestImagePreprocessor(TransactionCase): def _sample_image(self): import base64 From 1133eb9c73519baf14c0ff341d9e28c727fa4d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 06:35:34 +0300 Subject: [PATCH 22/60] [FIX] ai_document_extraction: skip pre-processor test when cv2 unimportable --- ai_document_extraction/tests/test_extraction.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ai_document_extraction/tests/test_extraction.py b/ai_document_extraction/tests/test_extraction.py index e04ce015..a56f06a4 100644 --- a/ai_document_extraction/tests/test_extraction.py +++ b/ai_document_extraction/tests/test_extraction.py @@ -1,16 +1,11 @@ # Copyright 2026 VSL # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -import importlib.util import os -from unittest import skipUnless from odoo.tests import TransactionCase -_HAVE_CV2 = importlib.util.find_spec("cv2") is not None - -@skipUnless(_HAVE_CV2, "OpenCV (cv2) not available") class TestImagePreprocessor(TransactionCase): def _sample_image(self): import base64 @@ -28,7 +23,12 @@ def test_preprocess_returns_file(self): from ..services.image_preprocessor import preprocess_image source = self._sample_image() - result = preprocess_image(source) + try: + result = preprocess_image(source) + except ImportError: + # cv2 (transitively pulled by paddleocr) may be unimportable in + # some environments, e.g. OCA CI images without libGL. + self.skipTest("OpenCV (cv2) not importable") try: self.assertTrue(os.path.exists(result)) self.assertTrue(result.endswith(".png")) From df2718179bd47f4f71b517552dd38e84ad8cedcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 11:28:00 +0300 Subject: [PATCH 23/60] [FIX] ai_document_extraction: default invoice_date to today on new moves The 'Extract with AI' button could not run on a freshly uploaded vendor bill because the web client saves the form before executing the button and invoice_date is required in the vendor bill form arch. Defaulting invoice_date to today makes the form always saveable; the extracted date still overrides it when the AI returns one. --- ai_document_extraction/models/account_move.py | 7 +++++++ ai_document_extraction/tests/test_extraction.py | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/ai_document_extraction/models/account_move.py b/ai_document_extraction/models/account_move.py index f3df4fb1..7b52e3d9 100644 --- a/ai_document_extraction/models/account_move.py +++ b/ai_document_extraction/models/account_move.py @@ -20,6 +20,13 @@ 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"), diff --git a/ai_document_extraction/tests/test_extraction.py b/ai_document_extraction/tests/test_extraction.py index a56f06a4..41de7cd1 100644 --- a/ai_document_extraction/tests/test_extraction.py +++ b/ai_document_extraction/tests/test_extraction.py @@ -228,6 +228,15 @@ 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", From a46cf6ff2a9962ec5805b25d1ae1f289f983f889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 11:54:10 +0300 Subject: [PATCH 24/60] [IMP] ai_document_extraction: UX and extraction quality improvements - Fix AI Extraction section layout: state badge in the header, extracted amounts and raw JSON moved to a dedicated notebook page (the previous nested group rendered the fields stacked and misaligned). - Add immediate feedback: chatter message when the extraction starts and a badge showing the processing state; hide the button while processing/done. - Store the OCR-ready processed image as an attachment on the move so both the original upload and the processed PNG are kept. - Allow customer invoices (out_invoice/out_receipt) in addition to vendor bills. - Improve the LLM prompt: extract the real issuer name (legal-suffix company names in the header are the partner, standalone logos are ignored) and extract invoice line items. - Apply extracted line items as invoice lines (falling back to a single untaxed line when no lines are visible). --- ai_document_extraction/models/account_move.py | 99 ++++++++++++++++--- .../services/llm_extractor.py | 29 ++++-- .../tests/test_extraction.py | 80 +++++++++++++++ .../views/account_move_views.xml | 34 +++++-- 4 files changed, 212 insertions(+), 30 deletions(-) diff --git a/ai_document_extraction/models/account_move.py b/ai_document_extraction/models/account_move.py index 7b52e3d9..4fb34c19 100644 --- a/ai_document_extraction/models/account_move.py +++ b/ai_document_extraction/models/account_move.py @@ -114,16 +114,20 @@ def action_extract_with_ai(self): raise UserError( self.env._("AI extraction is only available on draft moves.") ) - if self.move_type not in ("in_invoice", "in_receipt"): - raise UserError( - self.env._("AI extraction is only available on vendor bills.") - ) + 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 @@ -189,6 +193,25 @@ def _ai_cleanup_tmp(self, path): 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 @@ -208,19 +231,33 @@ def _match_partner(self, name, threshold): return best return None - def _ai_set_untaxed_line(self, untaxed): + def _ai_get_line_account(self): self.ensure_one() - account = self.env["account.account"].search( - [ - ("internal_group", "=", "expense"), - ("company_ids", "in", self.company_id.id), - ], - limit=1, - ) + 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_set_untaxed_line(self, untaxed): + self.ensure_one() + account = self._ai_get_line_account() stale = self.line_ids.filtered(lambda line: line.name == _DEFAULT_LINE_NAME) commands = [(2, line.id) for line in stale] commands.append( @@ -237,6 +274,33 @@ def _ai_set_untaxed_line(self, untaxed): ) self.line_ids = commands + def _ai_set_lines(self, lines): + 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 _DEFAULT_LINE_NAME).strip() + if not name: + continue + 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 + 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, + }, + ) + ) + if len(commands) > 1: + self.line_ids = commands + def _apply_extraction(self, data): self.ensure_one() values = {} @@ -262,9 +326,13 @@ def _apply_extraction(self, data): values["partner_id"] = partner.id if values: self.write(values) - untaxed = self._ai_to_float(data.get("amount_untaxed")) - if untaxed and untaxed > 0: - self._ai_set_untaxed_line(untaxed) + lines = data.get("lines") or [] + if lines: + self._ai_set_lines(lines) + else: + untaxed = self._ai_to_float(data.get("amount_untaxed")) + if untaxed and untaxed > 0: + self._ai_set_untaxed_line(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 @@ -289,6 +357,7 @@ def _extract_with_ai_job(self, attachment_id): settings["model_name"], settings["api_key"], ) + self._ai_store_processed_image(processed_path) self._apply_extraction(data) self.ai_raw_extraction = json.dumps(data, indent=2) self.ai_extraction_state = "done" diff --git a/ai_document_extraction/services/llm_extractor.py b/ai_document_extraction/services/llm_extractor.py index 4237e5c8..f52928da 100644 --- a/ai_document_extraction/services/llm_extractor.py +++ b/ai_document_extraction/services/llm_extractor.py @@ -7,11 +7,23 @@ SYSTEM_PROMPT = ( "You are a strict invoice data extraction assistant. You will receive OCR " - "text tagged with positional layouts ([HEADER], [BODY], [FOOTER]). Short texts " - "in [HEADER] are often logos or slogans (e.g. 'voslo') and MUST NOT be used as " - "the partner_name unless explicitly stated as the issuer. Extract real invoice " - "data only. Do not calculate missing values; output null if unknown. Respond " - "ONLY with a valid JSON object." + "text tagged with positional layouts ([HEADER], [BODY], [FOOTER]).\n" + "- The partner_name is the name of the company that issued the invoice " + "(the supplier for a vendor bill, the customer for a customer invoice). It " + "is usually written in the [HEADER] next to the word 'From', 'Supplier', " + "'Billed by', 'Issuer' or at the top of the document. A short standalone " + "text in [HEADER] that is just a logo or slogan (e.g. 'voslo') MUST NOT be " + "used as the partner_name; but a full company name with a legal suffix " + "such as A.Ş., Ltd., GmbH, Inc., S.L. IS the issuer and must be extracted.\n" + "- Extract real invoice data only. Do not calculate missing values; output " + "null if unknown.\n" + "- amount_untaxed is the subtotal (before tax), amount_tax the tax amount, " + "amount_total the final total. Read them from the document, never compute " + "them.\n" + "- Extract the invoice line items listed in the [BODY] (product or service " + "name, quantity and unit price when visible). If no line items are visible, " + "output an empty array.\n" + "Respond ONLY with a valid JSON object." ) EXPECTED_FIELDS = ( @@ -22,6 +34,7 @@ "amount_tax", "amount_total", "currency", + "lines", ) @@ -32,7 +45,9 @@ def _build_user_prompt(processed_text): '{"partner_name": , "invoice_number": , ' '"invoice_date": <"YYYY-MM-DD" or null>, "amount_untaxed": , ' '"amount_tax": , "amount_total": , ' - '"currency": }\n' + '"currency": , ' + '"lines": [{"name": , "quantity": , ' + '"price_unit": } or null]}\n' "Output ONLY the JSON object, with no markdown or extra text.\n\n" f"OCR text:\n{processed_text}" ) @@ -52,6 +67,8 @@ def _parse_json_response(content): 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]}") diff --git a/ai_document_extraction/tests/test_extraction.py b/ai_document_extraction/tests/test_extraction.py index 41de7cd1..d40271fc 100644 --- a/ai_document_extraction/tests/test_extraction.py +++ b/ai_document_extraction/tests/test_extraction.py @@ -255,6 +255,86 @@ def test_apply_extraction(self): 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_action_extract_with_ai_allows_customer_invoice(self): + from unittest import mock + + 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 unittest import mock + + from ..services import image_preprocessor, llm_extractor, ocr_engine + + 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( + image_preprocessor, + "preprocess_image", + return_value=png_path, + ), + mock.patch.object( + ocr_engine, + "extract_text_with_layout", + return_value="[BODY] Voslo Lojistik", + ), + mock.patch.object( + llm_extractor, + "extract_invoice_data", + return_value={"partner_name": "Voslo Lojistik", "lines": []}, + ), + ): + 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 unittest import mock diff --git a/ai_document_extraction/views/account_move_views.xml b/ai_document_extraction/views/account_move_views.xml index 743a4aca..53928509 100644 --- a/ai_document_extraction/views/account_move_views.xml +++ b/ai_document_extraction/views/account_move_views.xml @@ -11,6 +11,7 @@ type="object" string="Extract with AI" icon="fa-magic" + invisible="ai_extraction_state in ('processing', 'done')" /> @@ -22,17 +23,32 @@ invisible="ai_extraction_state != 'done'" /> - - - - - - - + + + + + - + + + + + + + + - + From 099fd989ce93d0013aebaf70a7d945c16afab2eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 12:48:45 +0300 Subject: [PATCH 25/60] [IMP] ai_document_extraction: structured extraction with per-line taxes - Feed the LLM the list of available taxes (id/name/rate) and active currency codes so it can pick the exact tax per invoice line and report the invoice currency instead of guessing. - Extract a 'description' field and structured 'lines' (name, quantity, price_unit, tax_id); create real invoice lines with their tax applied. The generic 'AI extracted amount' line name is removed: when no lines are visible a single line is created using the extracted description. - Apply the extracted currency to the move when it differs from the company currency; keep the company currency when the extraction has no currency. - Add anti-hallucination validation: reject hash-like/URL invoice numbers, model names or bare logos as partner, invalid/out-of-range dates, unknown currencies and tax ids. - Post a chatter warning when no invoice date could be extracted. --- ai_document_extraction/models/account_move.py | 84 +++++--- .../services/llm_extractor.py | 166 +++++++++++++++- .../tests/test_extraction.py | 179 +++++++++++++++++- 3 files changed, 393 insertions(+), 36 deletions(-) diff --git a/ai_document_extraction/models/account_move.py b/ai_document_extraction/models/account_move.py index 4fb34c19..655dbc2e 100644 --- a/ai_document_extraction/models/account_move.py +++ b/ai_document_extraction/models/account_move.py @@ -14,7 +14,6 @@ _logger = logging.getLogger(__name__) _IMAGE_EXTENSIONS = ("pdf", "png", "jpg", "jpeg", "gif", "bmp") -_DEFAULT_LINE_NAME = "AI extracted amount" class AccountMove(models.Model): @@ -255,37 +254,55 @@ def _ai_get_line_account(self): ) return account - def _ai_set_untaxed_line(self, untaxed): + def _ai_available_taxes(self): + """Taxes the LLM may assign to invoice lines, keyed for the prompt.""" self.ensure_one() - account = self._ai_get_line_account() - stale = self.line_ids.filtered(lambda line: line.name == _DEFAULT_LINE_NAME) - commands = [(2, line.id) for line in stale] - commands.append( - ( - 0, - 0, - { - "name": _DEFAULT_LINE_NAME, - "account_id": account.id, - "quantity": 1, - "price_unit": untaxed, - }, - ) + use = ( + "sale" + if self.move_type in ("out_invoice", "out_receipt", "out_refund") + else "purchase" ) - self.line_ids = commands + 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} for tax in taxes] + + def _ai_available_currencies(self): + return self.env["res.currency"].search([("active", "=", True)]).mapped("name") - def _ai_set_lines(self, lines): + def _ai_resolve_tax(self, tax_id): + """Resolve an LLM-selected tax id to an account.tax record (exact match).""" + self.ensure_one() + if not tax_id: + return self.env["account.tax"] + try: + tax_id = int(tax_id) + except (ValueError, TypeError): + return self.env["account.tax"] + available_ids = {tax["id"] for tax in self._ai_available_taxes()} + if tax_id not in available_ids: + return self.env["account.tax"] + return self.env["account.tax"].browse(tax_id) + + 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 _DEFAULT_LINE_NAME).strip() - if not name: - continue + 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")) commands.append( ( 0, @@ -295,11 +312,11 @@ def _ai_set_lines(self, lines): "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 [], }, ) ) - if len(commands) > 1: - self.line_ids = commands + self.line_ids = commands def _apply_extraction(self, data): self.ensure_one() @@ -314,6 +331,13 @@ def _apply_extraction(self, data): self.id, invoice_date, ) + else: + self.message_post( + body=self.env._( + "The invoice date could not be extracted; " + "please review the draft before posting." + ) + ) if data.get("invoice_number"): values["ref"] = data["invoice_number"] partner = None @@ -324,15 +348,23 @@ def _apply_extraction(self, data): ) 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) + 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_untaxed_line(untaxed) + 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 @@ -356,6 +388,8 @@ def _extract_with_ai_job(self, attachment_id): settings["api_base_url"], settings["model_name"], settings["api_key"], + available_taxes=self._ai_available_taxes(), + available_currencies=self._ai_available_currencies(), ) self._ai_store_processed_image(processed_path) self._apply_extraction(data) diff --git a/ai_document_extraction/services/llm_extractor.py b/ai_document_extraction/services/llm_extractor.py index f52928da..0ea2a79c 100644 --- a/ai_document_extraction/services/llm_extractor.py +++ b/ai_document_extraction/services/llm_extractor.py @@ -2,6 +2,8 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). import json +import re +from datetime import date, datetime import requests @@ -14,15 +16,25 @@ "'Billed by', 'Issuer' or at the top of the document. A short standalone " "text in [HEADER] that is just a logo or slogan (e.g. 'voslo') MUST NOT be " "used as the partner_name; but a full company name with a legal suffix " - "such as A.Ş., Ltd., GmbH, Inc., S.L. IS the issuer and must be extracted.\n" + "such as A.Ş., Ltd., GmbH, Inc., S.L. IS the issuer and must be extracted. " + "Never use your own model name (e.g. DeepSeek, qwen, GPT) as the issuer.\n" "- Extract real invoice data only. Do not calculate missing values; output " "null if unknown.\n" + "- invoice_number must be exactly as printed on the document (e.g. " + "'FT-2023-0042'). It can never be a URL, a file token or a long hex " + "hash; if it looks like one of those, output null.\n" "- amount_untaxed is the subtotal (before tax), amount_tax the tax amount, " "amount_total the final total. Read them from the document, never compute " "them.\n" + "- description is a short free-text summary of what the invoice is for " + "(e.g. the service or product category), or null.\n" "- Extract the invoice line items listed in the [BODY] (product or service " - "name, quantity and unit price when visible). If no line items are visible, " - "output an empty array.\n" + "name, quantity and unit price when visible). For each line pick the tax " + "from the provided 'Available taxes' list using its numeric id, or null " + "if no exact tax applies. If no line items are visible, output an empty " + "array.\n" + "- currency must be one of the provided 'Available currencies' (ISO code) " + "or null.\n" "Respond ONLY with a valid JSON object." ) @@ -34,20 +46,73 @@ "amount_tax", "amount_total", "currency", + "description", "lines", ) +_MODEL_NAMES = { + "deepseek", + "qwen", + "qwen2", + "qwen2.5", + "qwen3", + "llama", + "llama3", + "gpt", + "gpt-4", + "gpt-4o", + "claude", + "gemini", + "mistral", + "mixtral", + "phi", + "deepseek-v4-flash", +} -def _build_user_prompt(processed_text): +_LEGAL_SUFFIXES = ( + "a.ş.", + "a.s.", + "ltd.", + "ltd", + "gmbh", + "inc.", + "s.l.", + "s.a.", + "b.v.", + "sarl", + "llc", + "corp.", + "co.", +) + +_HASH_INVOICE_NUMBER = re.compile(r"[0-9a-fA-F]{16,}") + + +def _build_user_prompt(processed_text, available_taxes=None, available_currencies=None): + tax_block = "" + if available_taxes: + tax_lines = "\n".join( + f"- {tax['id']}: {tax['name']} ({tax['amount']}%)" + for tax in available_taxes + ) + tax_block = f"Available taxes (choose tax_id from this list):\n{tax_lines}\n" + currency_block = "" + if available_currencies: + currency_block = ( + f"Available currencies (ISO codes): {', '.join(available_currencies)}\n" + ) return ( "/no_think\n" "Extract the following fields from the OCR text as a single JSON object:\n" '{"partner_name": , "invoice_number": , ' '"invoice_date": <"YYYY-MM-DD" or null>, "amount_untaxed": , ' '"amount_tax": , "amount_total": , ' - '"currency": , ' + '"currency": , "description": , ' '"lines": [{"name": , "quantity": , ' - '"price_unit": } or null]}\n' + '"price_unit": , ' + '"tax_id": }]}\n' + f"{tax_block}" + f"{currency_block}" "Output ONLY the JSON object, with no markdown or extra text.\n\n" f"OCR text:\n{processed_text}" ) @@ -73,8 +138,82 @@ def _parse_json_response(content): raise ValueError(f"No JSON object found in LLM response: {content[:200]}") +def _validate_invoice_number(data): + candidate = str(data.get("invoice_number") or "").strip() + if ( + _HASH_INVOICE_NUMBER.fullmatch(candidate) + or "://" in candidate + or "%" in candidate + or "\\" in candidate + ): + data["invoice_number"] = 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_tax_ids): + if not available_tax_ids: + return + for line in data.get("lines") or []: + if not isinstance(line, dict): + continue + tax_id = line.get("tax_id") + if tax_id is None: + continue + try: + valid = int(tax_id) in available_tax_ids + except (ValueError, TypeError): + valid = False + if not valid: + line.pop("tax_id", None) + + +def _validate_data(data, available_tax_ids=None, available_currencies=None): + """Drop hallucinated values so only trustworthy fields reach the move.""" + _validate_invoice_number(data) + _validate_partner_name(data) + _validate_invoice_date(data) + _validate_currency(data, available_currencies) + _validate_lines(data, available_tax_ids) + return data + + def extract_invoice_data( - processed_text, api_base_url, api_model_name, api_key="dummy", timeout=120 + processed_text, + api_base_url, + api_model_name, + api_key="dummy", + timeout=120, + available_taxes=None, + available_currencies=None, ): """Call an OpenAI-compatible /chat/completions endpoint and return the dict.""" url = f"{api_base_url.rstrip('/')}/chat/completions" @@ -82,7 +221,12 @@ def extract_invoice_data( "model": api_model_name, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": _build_user_prompt(processed_text)}, + { + "role": "user", + "content": _build_user_prompt( + processed_text, available_taxes, available_currencies + ), + }, ], "temperature": 0, "stream": False, @@ -93,4 +237,8 @@ def extract_invoice_data( response = requests.post(url, json=payload, headers=headers, timeout=timeout) response.raise_for_status() content = response.json()["choices"][0]["message"]["content"] - return _parse_json_response(content) + data = _parse_json_response(content) + tax_ids = {int(tax["id"]) for tax in (available_taxes or [])} + return _validate_data( + data, available_tax_ids=tax_ids, available_currencies=available_currencies + ) diff --git a/ai_document_extraction/tests/test_extraction.py b/ai_document_extraction/tests/test_extraction.py index d40271fc..c57da558 100644 --- a/ai_document_extraction/tests/test_extraction.py +++ b/ai_document_extraction/tests/test_extraction.py @@ -149,7 +149,8 @@ def test_extract_invoice_data_posts_and_parses(self): "choices": [ { "message": { - "content": '{"partner_name": "Voslo", "amount_total": 118.0}' + "content": '{"partner_name": "Voslo Lojistik A.S.", ' + '"amount_total": 118.0}' } } ] @@ -162,7 +163,7 @@ def test_extract_invoice_data_posts_and_parses(self): "http://ollama:11434/v1", "qwen3:4b", ) - self.assertEqual(data["partner_name"], "Voslo") + self.assertEqual(data["partner_name"], "Voslo Lojistik A.S.") post_mock.assert_called_once() payload = post_mock.call_args.kwargs["json"] self.assertEqual(payload["model"], "qwen3:4b") @@ -190,6 +191,106 @@ def test_extract_invoice_data_sends_api_key(self): "Bearer secret", ) + def test_extract_invoice_data_sends_available_context(self): + from unittest import mock + + from ..services import llm_extractor + + response = mock.Mock() + response.status_code = 200 + response.json.return_value = { + "choices": [ + {"message": {"content": '{"partner_name": "Voslo", "lines": []}'}} + ] + } + with mock.patch.object( + llm_extractor.requests, "post", return_value=response + ) as post_mock: + llm_extractor.extract_invoice_data( + "[BODY] x", + "http://ollama:11434/v1", + "qwen3:4b", + available_taxes=[{"id": 34, "name": "20%", "amount": 20.0}], + available_currencies=["TRY", "USD"], + ) + user_content = post_mock.call_args.kwargs["json"]["messages"][1]["content"] + self.assertIn("Available taxes", user_content) + self.assertIn("20%", user_content) + self.assertIn("Available currencies", user_content) + self.assertIn("USD", user_content) + + def test_validate_rejects_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.assertIsNone(result["invoice_number"]) + + 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_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_id(self): + from ..services import llm_extractor + + data = { + "lines": [ + {"name": "Nakliye", "tax_id": 999}, + {"name": "Depolama", "tax_id": 34}, + ] + } + result = llm_extractor._validate_data(data, available_tax_ids={34}) + self.assertNotIn("tax_id", result["lines"][0]) + self.assertEqual(result["lines"][1]["tax_id"], 34) + class TestAccountMoveExtraction(TransactionCase): @classmethod @@ -271,6 +372,80 @@ def test_apply_extraction_with_lines(self): self.assertIn("Nakliye Hizmeti", line_names) self.assertIn("Depolama", line_names) + 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"].search( + [ + ("type_tax_use", "=", "purchase"), + ("amount", "=", 20.0), + ("amount_type", "=", "percent"), + ], + limit=1, + ) + self.assertTrue(tax) + data = { + "lines": [ + { + "name": "Nakliye Hizmeti", + "quantity": 1, + "price_unit": 100.0, + "tax_id": tax.id, + } + ] + } + 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_id": 999, + } + ] + } + 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_action_extract_with_ai_allows_customer_invoice(self): from unittest import mock From cb6ab727f6f85e58c8be56d1efbf8007590d87fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 12:53:18 +0300 Subject: [PATCH 26/60] [FIX] ai_document_extraction: make line-tax test independent of demo data Create a dedicated purchase tax inside the test instead of relying on the demo 20% tax, which is not present in every CI database. --- ai_document_extraction/tests/test_extraction.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ai_document_extraction/tests/test_extraction.py b/ai_document_extraction/tests/test_extraction.py index c57da558..3d0974b6 100644 --- a/ai_document_extraction/tests/test_extraction.py +++ b/ai_document_extraction/tests/test_extraction.py @@ -383,15 +383,15 @@ def test_apply_extraction_ignores_unknown_currency(self): 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"].search( - [ - ("type_tax_use", "=", "purchase"), - ("amount", "=", 20.0), - ("amount_type", "=", "percent"), - ], - limit=1, + 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, + } ) - self.assertTrue(tax) data = { "lines": [ { From 2474c8d77b70f6f5ae8c07ba82aec699d974fc75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 13:17:01 +0300 Subject: [PATCH 27/60] [IMP] ai_document_extraction: replace misleading upload error for images Uploading an image or PDF to a bill used to post Odoo's generic 'There was an error while importing the bill...' message because no EDI decoder applies to such files, even though the file was attached and the draft created. Override account.move._extend_with_attachments: when no EDI decoder handled the files but they can be processed by the AI extraction (images/PDFs), treat the upload as successful and post a message guiding the user to 'Extract with AI'. Non-processable files (e.g. XML) keep the original import error. --- ai_document_extraction/models/account_move.py | 30 ++++++++++++++ .../tests/test_extraction.py | 39 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/ai_document_extraction/models/account_move.py b/ai_document_extraction/models/account_move.py index 655dbc2e..b979907c 100644 --- a/ai_document_extraction/models/account_move.py +++ b/ai_document_extraction/models/account_move.py @@ -107,6 +107,36 @@ def _ai_get_attachment(self): return attachment return None + def _ai_is_processable(self, filename): + """Whether the uploaded file can be handled by the AI extraction.""" + return (filename or "").rsplit(".", 1)[-1].lower() 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": diff --git a/ai_document_extraction/tests/test_extraction.py b/ai_document_extraction/tests/test_extraction.py index 3d0974b6..72888c09 100644 --- a/ai_document_extraction/tests/test_extraction.py +++ b/ai_document_extraction/tests/test_extraction.py @@ -446,6 +446,45 @@ def test_apply_extraction_warns_when_date_missing(self): 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_action_extract_with_ai_allows_customer_invoice(self): from unittest import mock From eaa1771d305afeab5f5af71de38a2f963f712d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20TA=C5=9ECI?= Date: Thu, 6 Aug 2026 13:35:37 +0300 Subject: [PATCH 28/60] [IMP] ai_document_extraction: extract invoices with a vision LLM instead of OCR Replace the OCR + text pipeline (PaddleOCR + OpenCV preprocessing) with a direct vision-LLM call. The invoice image (PDF first page or uploaded image, capped at 1568px) is sent to the configured vision model (default qwen3-vl:8b) which returns the structured data in one shot. - The LLM call was the bottleneck (74s on qwen3:4b in thinking mode); the vision model reads the document directly, extracts the real invoice date, number, currency, amounts and line items, and completes in ~45s. - Per-line taxes are now matched by tax_rate against the available taxes (exact percent match), keeping lines untaxed when no tax matches. - Drop the PaddleOCR/OpenCV dependency and the ocr_language setting; the module now only needs Pillow, pdf2image, rapidfuzz and requests. --- ai_document_extraction/__manifest__.py | 4 +- ai_document_extraction/models/account_move.py | 106 +++++--- .../models/res_config_settings.py | 12 +- ai_document_extraction/services/__init__.py | 2 +- .../services/image_preprocessor.py | 50 ---- .../services/llm_extractor.py | 120 +++++---- ai_document_extraction/services/ocr_engine.py | 66 ----- .../tests/test_extraction.py | 247 ++++++------------ .../views/res_config_settings_views.xml | 10 +- requirements.txt | 2 +- 10 files changed, 233 insertions(+), 386 deletions(-) delete mode 100644 ai_document_extraction/services/image_preprocessor.py delete mode 100644 ai_document_extraction/services/ocr_engine.py diff --git a/ai_document_extraction/__manifest__.py b/ai_document_extraction/__manifest__.py index 3dc3606b..f7a1ef98 100644 --- a/ai_document_extraction/__manifest__.py +++ b/ai_document_extraction/__manifest__.py @@ -3,7 +3,7 @@ { "name": "AI Document Extraction", - "summary": "Extract invoice data from PDFs and images using local OCR and an LLM", + "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", @@ -14,7 +14,7 @@ "installable": True, "depends": ["base", "account", "queue_job"], "external_dependencies": { - "python": ["paddleocr", "pdf2image", "rapidfuzz", "requests"], + "python": ["Pillow", "pdf2image", "rapidfuzz", "requests"], }, "data": [ "security/ir.model.access.csv", diff --git a/ai_document_extraction/models/account_move.py b/ai_document_extraction/models/account_move.py index b979907c..d64237f3 100644 --- a/ai_document_extraction/models/account_move.py +++ b/ai_document_extraction/models/account_move.py @@ -9,7 +9,7 @@ from odoo import api, fields, models from odoo.exceptions import UserError -from ..services import image_preprocessor, llm_extractor, ocr_engine +from ..services import llm_extractor _logger = logging.getLogger(__name__) @@ -68,8 +68,7 @@ def _ai_settings(self): "api_base_url", "http://ollama:11434/v1" ), "api_key": self._ai_get_param("api_key", "dummy"), - "model_name": self._ai_get_param("model_name", "qwen3:4b"), - "ocr_language": self._ai_get_param("ocr_language", "tur+eng"), + "model_name": self._ai_get_param("model_name", "qwen3-vl:8b"), "fuzzy_match_threshold": int( self._ai_get_param("fuzzy_match_threshold", "85") ), @@ -189,6 +188,27 @@ def action_review_extraction(self): "target": "new", } + def _ai_resize_image(self, path, max_dimension=1568): + """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 = (attachment.name or "file").rsplit(".", 1)[-1].lower() @@ -201,17 +221,17 @@ def _ai_prepare_image(self, attachment): from pdf2image import convert_from_path images = convert_from_path( - file_path, dpi=300, first_page=1, last_page=1 + 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) - return png_path - return file_path + file_path = png_path + return self._ai_resize_image(file_path) except Exception: - os.unlink(file_path) + self._ai_cleanup_tmp(file_path) raise def _ai_cleanup_tmp(self, path): @@ -303,24 +323,45 @@ def _ai_available_taxes(self): ("company_id", "=", False), ] ) - return [{"id": tax.id, "name": tax.name, "amount": tax.amount} for tax in taxes] + 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): - """Resolve an LLM-selected tax id to an account.tax record (exact match).""" + 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() - if not tax_id: - return self.env["account.tax"] - try: - tax_id = int(tax_id) - except (ValueError, TypeError): - return self.env["account.tax"] - available_ids = {tax["id"] for tax in self._ai_available_taxes()} - if tax_id not in available_ids: - return self.env["account.tax"] - return self.env["account.tax"].browse(tax_id) + 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() @@ -332,7 +373,7 @@ def _ai_set_lines(self, lines, description=None): 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")) + tax = self._ai_resolve_tax(line.get("tax_id"), line.get("tax_rate")) commands.append( ( 0, @@ -402,26 +443,19 @@ def _apply_extraction(self, data): def _extract_with_ai_job(self, attachment_id): self.ensure_one() attachment = self.env["ir.attachment"].browse(attachment_id) - file_path = None - processed_path = None + image_path = None try: - file_path = self._ai_prepare_image(attachment) - processed_path = image_preprocessor.preprocess_image(file_path) + image_path = self._ai_prepare_image(attachment) settings = self._ai_settings() - ocr_text = ocr_engine.extract_text_with_layout( - processed_path, settings["ocr_language"] - ) - if not ocr_text.strip(): - raise UserError(self.env._("No text was detected in the document.")) - data = llm_extractor.extract_invoice_data( - ocr_text, + data = llm_extractor.extract_invoice_data_from_image( + image_path, settings["api_base_url"], settings["model_name"], settings["api_key"], available_taxes=self._ai_available_taxes(), available_currencies=self._ai_available_currencies(), ) - self._ai_store_processed_image(processed_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" @@ -433,7 +467,5 @@ def _extract_with_ai_job(self, attachment_id): ) self.message_post(body=self.env._("AI extraction failed: %s", error)) finally: - if processed_path: - self._ai_cleanup_tmp(processed_path) - if file_path: - self._ai_cleanup_tmp(file_path) + if image_path: + self._ai_cleanup_tmp(image_path) diff --git a/ai_document_extraction/models/res_config_settings.py b/ai_document_extraction/models/res_config_settings.py index 2b23a9df..c5c9ce2b 100644 --- a/ai_document_extraction/models/res_config_settings.py +++ b/ai_document_extraction/models/res_config_settings.py @@ -19,19 +19,9 @@ class ResConfigSettings(models.TransientModel): ) ai_model_name = fields.Char( string="AI Model Name", - default="qwen3:4b", + default="qwen3-vl:8b", config_parameter="ai_document_extraction.model_name", ) - ocr_language = fields.Selection( - [ - ("tur+eng", "Turkish + English"), - ("tur", "Turkish"), - ("eng", "English"), - ], - string="OCR Language", - default="tur+eng", - config_parameter="ai_document_extraction.ocr_language", - ) fuzzy_match_threshold = fields.Integer( string="Partner Match Threshold", default=85, diff --git a/ai_document_extraction/services/__init__.py b/ai_document_extraction/services/__init__.py index a9e09f78..ee5fa0a5 100644 --- a/ai_document_extraction/services/__init__.py +++ b/ai_document_extraction/services/__init__.py @@ -1 +1 @@ -from . import image_preprocessor, ocr_engine, llm_extractor +from . import llm_extractor diff --git a/ai_document_extraction/services/image_preprocessor.py b/ai_document_extraction/services/image_preprocessor.py deleted file mode 100644 index ba119bbb..00000000 --- a/ai_document_extraction/services/image_preprocessor.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright 2026 VSL -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - -import os -import tempfile - -MAX_DIM = 2000 - - -def preprocess_image(image_path): - """Enhance and resize an image for OCR. - - Converts to grayscale, applies CLAHE contrast enhancement, denoises with a - Gaussian blur, binarizes with an Otsu threshold and resizes down (keeping - aspect ratio) if the longest side exceeds ``MAX_DIM``. - - Returns the path to the processed PNG. The caller must delete it. - """ - # Limit the OpenMP runtime to a single thread before OpenCV is imported. - # In forked worker processes (e.g. the Odoo test runner) the inherited - # OpenMP thread-pool state crashes at import time with a SIGSEGV; the env - # variable must be set before ``import cv2`` and cv2.setNumThreads(1) alone - # does NOT prevent it. - os.environ["OMP_NUM_THREADS"] = "1" - import cv2 - - cv2.setNumThreads(1) - - img = cv2.imread(image_path) - if img is None: - raise ValueError(f"Could not read image: {image_path}") - gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) - clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) - enhanced = clahe.apply(gray) - blurred = cv2.GaussianBlur(enhanced, (5, 5), 0) - _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) - height, width = binary.shape - if max(width, height) > MAX_DIM: - scale = MAX_DIM / float(max(width, height)) - binary = cv2.resize( - binary, - (int(width * scale), int(height * scale)), - interpolation=cv2.INTER_AREA, - ) - handle, out_path = tempfile.mkstemp(suffix=".png") - os.close(handle) - if not cv2.imwrite(out_path, binary): - os.unlink(out_path) - raise ValueError(f"Could not write processed image: {out_path}") - return out_path diff --git a/ai_document_extraction/services/llm_extractor.py b/ai_document_extraction/services/llm_extractor.py index 0ea2a79c..04f9cbef 100644 --- a/ai_document_extraction/services/llm_extractor.py +++ b/ai_document_extraction/services/llm_extractor.py @@ -1,6 +1,7 @@ # Copyright 2026 VSL # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import base64 import json import re from datetime import date, datetime @@ -8,31 +9,32 @@ import requests SYSTEM_PROMPT = ( - "You are a strict invoice data extraction assistant. You will receive OCR " - "text tagged with positional layouts ([HEADER], [BODY], [FOOTER]).\n" - "- The partner_name is the name of the company that issued the invoice " - "(the supplier for a vendor bill, the customer for a customer invoice). It " - "is usually written in the [HEADER] next to the word 'From', 'Supplier', " - "'Billed by', 'Issuer' or at the top of the document. A short standalone " - "text in [HEADER] that is just a logo or slogan (e.g. 'voslo') MUST NOT be " - "used as the partner_name; but a full company name with a legal suffix " - "such as A.Ş., Ltd., GmbH, Inc., S.L. IS the issuer and must be extracted. " - "Never use your own model name (e.g. DeepSeek, qwen, GPT) as the issuer.\n" - "- Extract real invoice data only. Do not calculate missing values; output " - "null if unknown.\n" + "/no_think\n" + "You are a strict invoice data extraction assistant. You will receive an " + "image of an invoice or receipt. Extract the requested fields from the " + "image as a single JSON object.\n" + "- partner_name is the name of the company that issued the invoice (the " + "supplier for a vendor bill, the customer for a customer invoice). A " + "short logo or slogan in the document (e.g. 'voslo') MUST NOT be used as " + "the partner_name; only a full company name with a legal suffix such as " + "A.Ş., Ltd., GmbH, Inc., S.L. is the issuer. Never use your own model " + "name as the issuer.\n" + "- Extract real invoice data only. Do not calculate missing values; " + "output null if unknown.\n" "- invoice_number must be exactly as printed on the document (e.g. " "'FT-2023-0042'). It can never be a URL, a file token or a long hex " "hash; if it looks like one of those, output null.\n" + "- invoice_date must be 'YYYY-MM-DD' as printed on the document; null if " + "not visible.\n" "- amount_untaxed is the subtotal (before tax), amount_tax the tax amount, " "amount_total the final total. Read them from the document, never compute " "them.\n" "- description is a short free-text summary of what the invoice is for " "(e.g. the service or product category), or null.\n" - "- Extract the invoice line items listed in the [BODY] (product or service " - "name, quantity and unit price when visible). For each line pick the tax " - "from the provided 'Available taxes' list using its numeric id, or null " - "if no exact tax applies. If no line items are visible, output an empty " - "array.\n" + "- lines: each visible line item with its product or service name, " + "quantity and unit price. tax_rate must be a number matching one of the " + "provided 'Available tax rates' (e.g. 20 for 20%), or null when the line " + "has no tax.\n" "- currency must be one of the provided 'Available currencies' (ISO code) " "or null.\n" "Respond ONLY with a valid JSON object." @@ -56,6 +58,7 @@ "qwen2", "qwen2.5", "qwen3", + "qwen3-vl", "llama", "llama3", "gpt", @@ -88,33 +91,33 @@ _HASH_INVOICE_NUMBER = re.compile(r"[0-9a-fA-F]{16,}") -def _build_user_prompt(processed_text, available_taxes=None, available_currencies=None): +def _build_user_prompt(available_taxes=None, available_currencies=None): tax_block = "" if available_taxes: tax_lines = "\n".join( - f"- {tax['id']}: {tax['name']} ({tax['amount']}%)" - for tax in available_taxes + 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" ) - tax_block = f"Available taxes (choose tax_id from this list):\n{tax_lines}\n" currency_block = "" if available_currencies: currency_block = ( f"Available currencies (ISO codes): {', '.join(available_currencies)}\n" ) return ( - "/no_think\n" - "Extract the following fields from the OCR text as a single JSON object:\n" + "Extract the following fields from the invoice image as a single JSON " + "object:\n" '{"partner_name": , "invoice_number": , ' '"invoice_date": <"YYYY-MM-DD" or null>, "amount_untaxed": , ' '"amount_tax": , "amount_total": , ' '"currency": , "description": , ' '"lines": [{"name": , "quantity": , ' - '"price_unit": , ' - '"tax_id": }]}\n' + '"price_unit": , "tax_rate": }]}\n' f"{tax_block}" f"{currency_block}" - "Output ONLY the JSON object, with no markdown or extra text.\n\n" - f"OCR text:\n{processed_text}" + "Output ONLY the JSON object, with no markdown or extra text." ) @@ -179,35 +182,48 @@ def _validate_currency(data, available_currencies): data["currency"] = None -def _validate_lines(data, available_tax_ids): - if not available_tax_ids: +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 None: - continue - try: - valid = int(tax_id) in available_tax_ids - except (ValueError, TypeError): - valid = False - if not valid: - line.pop("tax_id", None) + 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_tax_ids=None, available_currencies=None): +def _validate_data(data, available_taxes=None, available_currencies=None): """Drop hallucinated values so only trustworthy fields reach the move.""" _validate_invoice_number(data) _validate_partner_name(data) _validate_invoice_date(data) _validate_currency(data, available_currencies) - _validate_lines(data, available_tax_ids) + _validate_lines(data, available_taxes) return data -def extract_invoice_data( - processed_text, +def extract_invoice_data_from_image( + image_path, api_base_url, api_model_name, api_key="dummy", @@ -215,7 +231,9 @@ def extract_invoice_data( available_taxes=None, available_currencies=None, ): - """Call an OpenAI-compatible /chat/completions endpoint and return the dict.""" + """Send the invoice image to a vision LLM and return the extracted dict.""" + with open(image_path, "rb") as image_file: + encoded = base64.b64encode(image_file.read()).decode() url = f"{api_base_url.rstrip('/')}/chat/completions" payload = { "model": api_model_name, @@ -223,9 +241,18 @@ def extract_invoice_data( {"role": "system", "content": SYSTEM_PROMPT}, { "role": "user", - "content": _build_user_prompt( - processed_text, available_taxes, available_currencies - ), + "content": [ + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{encoded}"}, + }, + { + "type": "text", + "text": _build_user_prompt( + available_taxes, available_currencies + ), + }, + ], }, ], "temperature": 0, @@ -238,7 +265,6 @@ def extract_invoice_data( response.raise_for_status() content = response.json()["choices"][0]["message"]["content"] data = _parse_json_response(content) - tax_ids = {int(tax["id"]) for tax in (available_taxes or [])} return _validate_data( - data, available_tax_ids=tax_ids, available_currencies=available_currencies + data, available_taxes=available_taxes, available_currencies=available_currencies ) diff --git a/ai_document_extraction/services/ocr_engine.py b/ai_document_extraction/services/ocr_engine.py deleted file mode 100644 index 181a7f1f..00000000 --- a/ai_document_extraction/services/ocr_engine.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright 2026 VSL -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - -import threading - -_PADDLE_LANG_MAP = { - "tur+eng": "latin", - "tur": "latin", - "eng": "en", -} - -_thread_local = threading.local() - - -def _get_ocr(ocr_language): - """Create (once per thread) a PaddleOCR instance for the given language.""" - import paddleocr - - lang = _PADDLE_LANG_MAP.get(ocr_language, "latin") - ocr = getattr(_thread_local, "ocr", None) - ocr_lang = getattr(_thread_local, "ocr_lang", None) - if ocr is None or ocr_lang != lang: - _thread_local.ocr = paddleocr.PaddleOCR(lang=lang, use_angle_cls=True) - _thread_local.ocr_lang = lang - return _thread_local.ocr - - -def extract_text_with_layout(image_path, ocr_language="tur+eng", image_height=None): - """Run OCR and tag each line with a positional [HEADER]/[BODY]/[FOOTER]. - - The top 20% of the page is tagged [HEADER], the bottom 20% [FOOTER] and - everything in between [BODY], based on the vertical center of each text - line. This lets the LLM ignore logo/slogan texts found in the header. - - Returns one "[TAG] text" line per OCR line, joined by newlines. - """ - if image_height is None: - import cv2 - - img = cv2.imread(image_path) - if img is None: - raise ValueError(f"Could not read image: {image_path}") - image_height = img.shape[0] - if image_height <= 0: - raise ValueError(f"Invalid image height: {image_height}") - ocr = _get_ocr(ocr_language) - result = ocr.ocr(image_path, cls=True) - lines = [] - if not result: - return "" - for page in result: - if not page: - continue - for box, (text, _score) in page: - ys = [point[1] for point in box] - center_y = sum(ys) / len(ys) - ratio = center_y / float(image_height) - if ratio < 0.2: - tag = "[HEADER]" - elif ratio > 0.8: - tag = "[FOOTER]" - else: - tag = "[BODY]" - if text and text.strip(): - lines.append(f"{tag} {text.strip()}") - return "\n".join(lines) diff --git a/ai_document_extraction/tests/test_extraction.py b/ai_document_extraction/tests/test_extraction.py index 72888c09..6a2f95fe 100644 --- a/ai_document_extraction/tests/test_extraction.py +++ b/ai_document_extraction/tests/test_extraction.py @@ -6,92 +6,18 @@ from odoo.tests import TransactionCase -class TestImagePreprocessor(TransactionCase): - def _sample_image(self): +class TestLlmExtractor(TransactionCase): + def _sample_png(self, path="/tmp/ai_sample_vl.png"): import base64 png = ( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8B" "QDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" ) - path = "/tmp/test_sample.png" with open(path, "wb") as handle: handle.write(base64.b64decode(png)) return path - def test_preprocess_returns_file(self): - from ..services.image_preprocessor import preprocess_image - - source = self._sample_image() - try: - result = preprocess_image(source) - except ImportError: - # cv2 (transitively pulled by paddleocr) may be unimportable in - # some environments, e.g. OCA CI images without libGL. - self.skipTest("OpenCV (cv2) not importable") - try: - self.assertTrue(os.path.exists(result)) - self.assertTrue(result.endswith(".png")) - finally: - os.unlink(result) - - -class TestOcrEngine(TransactionCase): - def test_layout_tags(self): - from unittest import mock - - from ..services import ocr_engine - - def fake_ocr(image_path, cls=True): - return [ - [ - ([(0, 10), (100, 10), (100, 30), (0, 30)], ("voslo", 0.99)), - ( - [(0, 300), (100, 300), (100, 320), (0, 320)], - ("Invoice No: 123", 0.99), - ), - ( - [(0, 650), (100, 650), (100, 670), (0, 670)], - ("page 1 of 1", 0.99), - ), - ] - ] - - with mock.patch.object( - ocr_engine, "_get_ocr", return_value=mock.Mock(ocr=fake_ocr) - ): - result = ocr_engine.extract_text_with_layout( - "/tmp/fake.png", image_height=700 - ) - self.assertIn("[HEADER] voslo", result) - self.assertIn("[BODY] Invoice No: 123", result) - self.assertIn("[FOOTER] page 1 of 1", result) - - def test_get_ocr_caches_instance_per_language(self): - import sys - from unittest import mock - - from ..services import ocr_engine - - class FakePaddle: - def __init__(self, **kwargs): - self.kwargs = kwargs - - fake_module = mock.Mock() - fake_module.PaddleOCR = FakePaddle - with mock.patch.dict(sys.modules, {"paddleocr": fake_module}): - ocr_engine._thread_local.ocr = None - ocr_engine._thread_local.ocr_lang = None - first = ocr_engine._get_ocr("tur+eng") - second = ocr_engine._get_ocr("tur+eng") - self.assertIs(first, second) - self.assertEqual(first.kwargs["lang"], "latin") - other = ocr_engine._get_ocr("eng") - self.assertIsNot(first, other) - self.assertEqual(other.kwargs["lang"], "en") - - -class TestLlmExtractor(TransactionCase): def test_parse_json_from_noisy_content(self): from ..services import llm_extractor @@ -138,7 +64,7 @@ def test_parse_json_raises_for_list(self): with self.assertRaises(ValueError): llm_extractor._parse_json_response("[1, 2, 3]") - def test_extract_invoice_data_posts_and_parses(self): + def test_extract_invoice_data_from_image_posts_and_parses(self): from unittest import mock from ..services import llm_extractor @@ -155,22 +81,24 @@ def test_extract_invoice_data_posts_and_parses(self): } ] } + path = self._sample_png() with mock.patch.object( llm_extractor.requests, "post", return_value=response ) as post_mock: - data = llm_extractor.extract_invoice_data( - "[BODY] Invoice No: 1", - "http://ollama:11434/v1", - "qwen3:4b", + data = llm_extractor.extract_invoice_data_from_image( + path, "http://ollama:11434/v1", "qwen3-vl:8b" ) self.assertEqual(data["partner_name"], "Voslo Lojistik A.S.") post_mock.assert_called_once() payload = post_mock.call_args.kwargs["json"] - self.assertEqual(payload["model"], "qwen3:4b") + self.assertEqual(payload["model"], "qwen3-vl:8b") self.assertEqual(payload["temperature"], 0) + user_content = payload["messages"][1]["content"] + self.assertEqual(user_content[0]["type"], "image_url") + self.assertIn("data:image/png;base64,", user_content[0]["image_url"]["url"]) self.assertNotIn("Authorization", post_mock.call_args.kwargs["headers"]) - def test_extract_invoice_data_sends_api_key(self): + def test_extract_invoice_data_from_image_sends_api_key(self): from unittest import mock from ..services import llm_extractor @@ -180,18 +108,19 @@ def test_extract_invoice_data_sends_api_key(self): response.json.return_value = { "choices": [{"message": {"content": '{"invoice_number": "X"}'}}] } + path = self._sample_png() with mock.patch.object( llm_extractor.requests, "post", return_value=response ) as post_mock: - llm_extractor.extract_invoice_data( - "text", "http://host:11434/v1", "m", api_key="secret" + llm_extractor.extract_invoice_data_from_image( + path, "http://host:11434/v1", "m", api_key="secret" ) self.assertEqual( post_mock.call_args.kwargs["headers"]["Authorization"], "Bearer secret", ) - def test_extract_invoice_data_sends_available_context(self): + def test_extract_invoice_data_from_image_sends_available_context(self): from unittest import mock from ..services import llm_extractor @@ -200,24 +129,37 @@ def test_extract_invoice_data_sends_available_context(self): response.status_code = 200 response.json.return_value = { "choices": [ - {"message": {"content": '{"partner_name": "Voslo", "lines": []}'}} + { + "message": { + "content": '{"partner_name": "Voslo Lojistik A.S.", ' + '"lines": []}' + } + } ] } + path = self._sample_png() with mock.patch.object( llm_extractor.requests, "post", return_value=response ) as post_mock: - llm_extractor.extract_invoice_data( - "[BODY] x", + llm_extractor.extract_invoice_data_from_image( + path, "http://ollama:11434/v1", - "qwen3:4b", - available_taxes=[{"id": 34, "name": "20%", "amount": 20.0}], + "qwen3-vl:8b", + available_taxes=[ + { + "id": 34, + "name": "20%", + "amount": 20.0, + "amount_type": "percent", + } + ], available_currencies=["TRY", "USD"], ) - user_content = post_mock.call_args.kwargs["json"]["messages"][1]["content"] - self.assertIn("Available taxes", user_content) - self.assertIn("20%", user_content) - self.assertIn("Available currencies", user_content) - self.assertIn("USD", user_content) + text = post_mock.call_args.kwargs["json"]["messages"][1]["content"][1]["text"] + self.assertIn("Available tax rates", text) + self.assertIn("20%", text) + self.assertIn("Available currencies", text) + self.assertIn("USD", text) def test_validate_rejects_hash_invoice_number(self): from ..services import llm_extractor @@ -278,18 +220,35 @@ def test_validate_drops_unknown_currency(self): ) self.assertIsNone(result["currency"]) - def test_validate_removes_unknown_tax_id(self): + def test_validate_removes_unknown_tax_rate(self): from ..services import llm_extractor data = { "lines": [ - {"name": "Nakliye", "tax_id": 999}, - {"name": "Depolama", "tax_id": 34}, + {"name": "Nakliye", "tax_rate": 18}, + {"name": "Depolama", "tax_rate": 20}, ] } - result = llm_extractor._validate_data(data, available_tax_ids={34}) + 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]) - self.assertEqual(result["lines"][1]["tax_id"], 34) class TestAccountMoveExtraction(TransactionCase): @@ -398,7 +357,7 @@ def test_apply_extraction_applies_line_tax(self): "name": "Nakliye Hizmeti", "quantity": 1, "price_unit": 100.0, - "tax_id": tax.id, + "tax_rate": 20.0, } ] } @@ -415,7 +374,7 @@ def test_apply_extraction_line_untaxed_when_tax_unknown(self): "name": "Nakliye Hizmeti", "quantity": 1, "price_unit": 100.0, - "tax_id": 999, + "tax_rate": 18.0, } ] } @@ -509,7 +468,7 @@ def test_job_stores_processed_image(self): import base64 from unittest import mock - from ..services import image_preprocessor, llm_extractor, ocr_engine + from ..services import llm_extractor png_path = "/tmp/ai_processed_test.png" png = ( @@ -519,22 +478,10 @@ def test_job_stores_processed_image(self): with open(png_path, "wb") as handle: handle.write(base64.b64decode(png)) attachment = self._attach() - with ( - mock.patch.object( - image_preprocessor, - "preprocess_image", - return_value=png_path, - ), - mock.patch.object( - ocr_engine, - "extract_text_with_layout", - return_value="[BODY] Voslo Lojistik", - ), - mock.patch.object( - llm_extractor, - "extract_invoice_data", - return_value={"partner_name": "Voslo Lojistik", "lines": []}, - ), + with mock.patch.object( + llm_extractor, + "extract_invoice_data_from_image", + return_value={"partner_name": "Voslo Lojistik", "lines": []}, ): self.move._extract_with_ai_job(attachment.id) if os.path.exists(png_path): @@ -552,33 +499,21 @@ def test_job_stores_processed_image(self): def test_job_happy_path(self): from unittest import mock - from ..services import image_preprocessor, llm_extractor, ocr_engine + from ..services import llm_extractor attachment = self._attach() - with ( - mock.patch.object( - image_preprocessor, - "preprocess_image", - return_value="/tmp/pp.png", - ), - mock.patch.object( - ocr_engine, - "extract_text_with_layout", - return_value="[BODY] Voslo Lojistik\n[BODY] Invoice No: FT-123", - ), - mock.patch.object( - llm_extractor, - "extract_invoice_data", - return_value={ - "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( + llm_extractor, + "extract_invoice_data_from_image", + return_value={ + "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", + }, ): self.move._extract_with_ai_job(attachment.id) self.assertEqual(self.move.ai_extraction_state, "done") @@ -588,25 +523,13 @@ def test_job_happy_path(self): def test_job_error_path(self): from unittest import mock - from ..services import image_preprocessor, llm_extractor, ocr_engine + from ..services import llm_extractor attachment = self._attach() - with ( - mock.patch.object( - image_preprocessor, - "preprocess_image", - return_value="/tmp/pp.png", - ), - mock.patch.object( - ocr_engine, - "extract_text_with_layout", - return_value="[BODY] x", - ), - mock.patch.object( - llm_extractor, - "extract_invoice_data", - side_effect=RuntimeError("boom"), - ), + with mock.patch.object( + llm_extractor, + "extract_invoice_data_from_image", + side_effect=RuntimeError("boom"), ): self.move._extract_with_ai_job(attachment.id) self.assertEqual(self.move.ai_extraction_state, "error") diff --git a/ai_document_extraction/views/res_config_settings_views.xml b/ai_document_extraction/views/res_config_settings_views.xml index c1b3da57..4ee9c73a 100644 --- a/ai_document_extraction/views/res_config_settings_views.xml +++ b/ai_document_extraction/views/res_config_settings_views.xml @@ -10,7 +10,7 @@
    @@ -41,14 +41,6 @@ />
    -
    -