From 5a33dde8a3fd632c525b20a9f13ee388e9867a4d 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 01/58] [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 0d44ac84058bd254439e7ae1a447c82db083e981 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 02/58] [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 b211bc80bd07e55110e3c70e22a5bb387888c462 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 03/58] [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 f9cc60c1324bea794fcfa5f23b5116e08892ca64 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 04/58] [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 0eeaddec2a3bd33be7bf248d98ebd67b78fad6cf 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 05/58] [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 3f13b2e84740185e5cf56632d662a664f454fe91 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 06/58] [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 32723bc24898c45ae1c258c374b90a98fca83cbc 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 07/58] [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 94588517d3d384dab0131b691e24c39b8dc042a3 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 08/58] [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 175ed5aecf5367d817a179104f48e5f191ca6901 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 09/58] [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 626c7cf3a936603565795133c2e18a1acfd9c386 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 10/58] [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 ea6465599d15387fc872d4c6d8e960dcd0902698 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 11/58] [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 b6af2115faa4a61d3c42ea39c308ce931b4d9eb6 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 12/58] [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 15c5a41b1a6c2105f38f1a2b176ca5692b039ed6 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 13/58] [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 f27125f2864d2fa3115afa584a53e9816a295797 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 19/58] [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 6b2115ac34a7f235b3513eace019c45a2029790a 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 20/58] [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 e4bbdc9d08695594dafe3b50a882bd38910e252e 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 21/58] [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 9365dd84cfc9311d8161b6d6685d3d8dbc563065 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 22/58] [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 fe207d068d2bd0d2ab63593109382dc21bf44d70 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 23/58] [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 384f96f87722747385634ecbd93af7f5ee338af4 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 24/58] [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 3fa939472a8599cf230261ce04a5cf0e4eba4525 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 25/58] [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 7664427a7f5fa36999f4f888525e4296f2b1f511 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 26/58] [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 @@ />
    -
    -