From 0c3c8454debaa7d1f4ce097d150b550d6cfae309 Mon Sep 17 00:00:00 2001 From: Enric Tobella Date: Mon, 6 Jul 2026 16:14:50 +0200 Subject: [PATCH 1/3] Update from template --- .copier-answers.yml | 3 ++- .github/workflows/pre-commit.yml | 3 ++- .github/workflows/test.yml | 4 ++++ .pre-commit-config.yaml | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.copier-answers.yml b/.copier-answers.yml index 5ccf52e7..81365df0 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Do NOT update manually; changes here will be overwritten by Copier -_commit: v1.40 +_commit: v1.43 _src_path: git+https://github.com/OCA/oca-addons-repo-template convert_readme_fragments_to_markdown: false enable_checklog_odoo: false @@ -15,6 +15,7 @@ odoo_test_flavor: Both odoo_version: 16.0 org_name: Odoo Community Association (OCA) org_slug: OCA +postgres_image: pgvector/pgvector:pg16 rebel_module_groups: [] repo_description: ai repo_name: ai diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 43b82fe8..36e6e44e 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -1,3 +1,4 @@ + name: pre-commit on: @@ -16,7 +17,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.10" cache: 'pip' cache-dependency-path: '.pre-commit-config.yaml' - name: Get python version diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5ec4eb78..8b86ca6c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,7 +42,11 @@ jobs: makepot: "true" services: postgres: +<<<<<<< before updating image: postgres:12 +======= + image: pgvector/pgvector:pg16 +>>>>>>> after updating env: POSTGRES_USER: odoo POSTGRES_PASSWORD: odoo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5a72e969..7bf4fc0e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ exclude: | # You don't usually want a bot to modify your legal texts (LICENSE.*|COPYING.*) default_language_version: - python: python3 + python: python3.10 node: "16.17.0" repos: - repo: local From 6a8c9d5aebba403b0ce6c0178b797924ba754b0e Mon Sep 17 00:00:00 2001 From: Enric Tobella Date: Mon, 1 Jun 2026 13:27:00 +0200 Subject: [PATCH 2/3] [ADD] field_vector_config --- field_vector_config/README.rst | 145 ++++++ field_vector_config/__init__.py | 2 + field_vector_config/__manifest__.py | 19 + field_vector_config/fields.py | 43 ++ field_vector_config/models/__init__.py | 3 + field_vector_config/models/base.py | 82 +++ .../models/ir_model_field_vector.py | 110 ++++ field_vector_config/models/ir_model_fields.py | 17 + field_vector_config/pyproject.toml | 3 + field_vector_config/readme/CONFIGURE.md | 19 + field_vector_config/readme/CONTEXT.md | 3 + field_vector_config/readme/CONTRIBUTORS.md | 2 + field_vector_config/readme/DESCRIPTION.md | 2 + field_vector_config/readme/USAGE.md | 24 + .../security/ir.model.access.csv | 2 + .../static/description/icon.png | Bin 0 -> 9455 bytes .../static/description/index.html | 489 ++++++++++++++++++ field_vector_config/tests/__init__.py | 2 + field_vector_config/tests/fake_models.py | 15 + .../tests/fake_models_compute.py | 24 + field_vector_config/tests/test_vector.py | 347 +++++++++++++ .../tests/test_vector_compute.py | 97 ++++ .../views/ir_model_field_vector.xml | 91 ++++ test-requirements.txt | 1 + 24 files changed, 1542 insertions(+) create mode 100644 field_vector_config/README.rst create mode 100644 field_vector_config/__init__.py create mode 100644 field_vector_config/__manifest__.py create mode 100644 field_vector_config/fields.py create mode 100644 field_vector_config/models/__init__.py create mode 100644 field_vector_config/models/base.py create mode 100644 field_vector_config/models/ir_model_field_vector.py create mode 100644 field_vector_config/models/ir_model_fields.py create mode 100644 field_vector_config/pyproject.toml create mode 100644 field_vector_config/readme/CONFIGURE.md create mode 100644 field_vector_config/readme/CONTEXT.md create mode 100644 field_vector_config/readme/CONTRIBUTORS.md create mode 100644 field_vector_config/readme/DESCRIPTION.md create mode 100644 field_vector_config/readme/USAGE.md create mode 100644 field_vector_config/security/ir.model.access.csv create mode 100644 field_vector_config/static/description/icon.png create mode 100644 field_vector_config/static/description/index.html create mode 100644 field_vector_config/tests/__init__.py create mode 100644 field_vector_config/tests/fake_models.py create mode 100644 field_vector_config/tests/fake_models_compute.py create mode 100644 field_vector_config/tests/test_vector.py create mode 100644 field_vector_config/tests/test_vector_compute.py create mode 100644 field_vector_config/views/ir_model_field_vector.xml diff --git a/field_vector_config/README.rst b/field_vector_config/README.rst new file mode 100644 index 00000000..8765b097 --- /dev/null +++ b/field_vector_config/README.rst @@ -0,0 +1,145 @@ +================= +Field Vector Fill +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:722ec05a7f6f24bb142697333fdd71c40ae7a2539242bd672c0affe2056d77c6 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fai-lightgray.png?logo=github + :target: https://github.com/OCA/ai/tree/18.0/field_vector_config + :alt: OCA/ai +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/ai-18-0/ai-18-0-field_vector_config + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to configure vector fields dynamically. Quite +interesting if you want to handle them by using AI. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +The original field_vector module implements vector configuration, +however, it is hard to handle how to create and search them. + +With this module, we added the functionality to do it. + +Configuration +============= + +With this module, we can easily add a configuration on a vector to +configure it dynamically on our database. + +.. code:: python + + from odoo.addons.field_vector_config.fields import ComputedVector + + + class ResPartner(models.Model): + _inherit = "res.partner" + + embedding = ComputedVector(string="Embedding") + +With that, we can go to **Settings / Technical / Database Structure** to +add the field manually and configure it. There you can configure the +size of the vector (depends on the method and model), computation +information and so on. + +Important notes: + +- If you make a field that is computed, we recommend to create it in a + pre_init_hook to avoid the creation and allow the user to configure it + properly +- If you change the size of the vector, update the column. It will + update the size and do nothing if it has the proper size. + +Usage +===== + +After the configuration, we can easily compute and search using this +vector. For example: + +.. code:: python + + from odoo.addons.ai_tool.tools import aitool + + + class ProductProduct(models.Model): + + _inherit = "product.product" + + product_vector = ComputedVector( + compute="_compute_product_vector", + store=True, + ) + + @api.depends("name", "description") + def _compute_product_vector(self): + for record in self: + record.product_vector = record._encode_vector("product_vector", f"{record.name}\n{record.description}\n{record.description_purchase}")[0] + + def _find_vector_product(self, value, limit=5): + records = self.search_vector("product_vector", value, limit=limit) + return [{"id": r.id, "name": r.name, "description": r.description} for r in records] + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Dixmit + +Contributors +------------ + +- `Dixmit `__ + + - Enric Tobella + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/ai `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/field_vector_config/__init__.py b/field_vector_config/__init__.py new file mode 100644 index 00000000..fedc70d8 --- /dev/null +++ b/field_vector_config/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import fields diff --git a/field_vector_config/__manifest__.py b/field_vector_config/__manifest__.py new file mode 100644 index 00000000..bf548d10 --- /dev/null +++ b/field_vector_config/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Field Vector Fill", + "summary": """Autogenerate vectors and add search functions""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Dixmit,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/ai", + "depends": [ + "field_vector", + ], + "data": [ + "security/ir.model.access.csv", + "views/ir_model_field_vector.xml", + ], + "demo": [], +} diff --git a/field_vector_config/fields.py b/field_vector_config/fields.py new file mode 100644 index 00000000..2f32de71 --- /dev/null +++ b/field_vector_config/fields.py @@ -0,0 +1,43 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.addons.field_vector.fields import Vector + + +def split_text_chunks(text, max_words=350, overlap=50): + words = text.split() + chunks = [] + start = 0 + while start < len(words): + chunk = " ".join(words[start : start + max_words]) + chunks.append(chunk) + start += max_words - overlap + return chunks + + +class ComputedVector(Vector): + type = "computed_vector" + + def __init__( + self, + dimensions=10, + **kwargs, + ): + # We set a default dimensions value + super().__init__(dimensions=dimensions, **kwargs) + + @property + def column_type(self): + return ("vector", "vector") + + def vector_dimensions(self, model): + dimensions = ( + model.env["ir.model.fields"] + .sudo() + .search( + [("model", "=", model._name), ("name", "=", self.name)], + limit=1, + ) + .vector_configuration_ids.dimensions + ) + return dimensions or super().vector_dimensions(model) diff --git a/field_vector_config/models/__init__.py b/field_vector_config/models/__init__.py new file mode 100644 index 00000000..23ef1012 --- /dev/null +++ b/field_vector_config/models/__init__.py @@ -0,0 +1,3 @@ +from . import ir_model_field_vector +from . import ir_model_fields +from . import base diff --git a/field_vector_config/models/base.py b/field_vector_config/models/base.py new file mode 100644 index 00000000..51a04f8d --- /dev/null +++ b/field_vector_config/models/base.py @@ -0,0 +1,82 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.exceptions import UserError +from odoo.tools import sql + +from ..fields import ComputedVector + + +class Base(models.AbstractModel): + _inherit = "base" + + @api.model + @api.readonly + @api.returns("self") + def search_vector(self, field_name, data, domain=None, limit=5, minim=0.5): + if domain is None: + domain = [] + if not isinstance(self._fields[field_name], ComputedVector): + raise UserError(f"Field {field_name} is not a computed_vector field") + to_search = self._encode_vector(field_name, data, is_search=True)[0] + query = self._search(domain, limit=limit) + distance = sql.SQL( + "%s <=> %s::vector", + self._field_to_sql(self._table, field_name, query), + to_search, + ) + sql_terms = [ + self._field_to_sql(self._table, "id", "query"), + sql.SQL("%s as distance", distance), + ] + query.order = "distance ASC" + if minim: + query.add_where(sql.SQL("%s < %s", distance, minim)) + self.env.cr.execute(query.select(*sql_terms)) + return self.browse([row[0] for row in self.env.cr.fetchall()]) + + @api.model + @api.readonly + @api.returns("self") + def search_vector_grouped( + self, field_name, data, final_field, domain=None, limit=5, minim=0.5 + ): + if domain is None: + domain = [] + if not isinstance(self._fields[field_name], ComputedVector): + raise UserError(f"Field {field_name} is not a computed_vector field") + if not isinstance(self._fields[final_field], fields.Many2one): + raise UserError(f"Field {final_field} is not a Many2one field") + to_search = self._encode_vector(field_name, data, is_search=True)[0] + query = self._search(domain, limit=limit) + distance = sql.SQL( + "MIN(%s <=> %s::vector)", + self._field_to_sql(self._table, field_name, query), + to_search, + ) + sql_terms = [ + self._field_to_sql(self._table, final_field, "query"), + sql.SQL("%s as distance", distance), + ] + query.order = "distance ASC" + query.groupby = self._field_to_sql(self._table, final_field, query) + if minim: + query.having = sql.SQL("%s < %s", distance, minim) + self.env.cr.execute(query.select(*sql_terms)) + rows = self.env.cr.fetchall() + return self[final_field].browse([row[0] for row in rows]) + + def _encode_vector(self, field_name, data, is_search=False): + config = ( + self.env["ir.model.fields"] + .sudo() + .search( + [("model", "=", self._name), ("name", "=", field_name)], + limit=1, + ) + .vector_configuration_ids + ) + if not config: + raise UserError(f"Field {field_name} is not configured") + return config._encode_vector(data, is_search=is_search) diff --git a/field_vector_config/models/ir_model_field_vector.py b/field_vector_config/models/ir_model_field_vector.py new file mode 100644 index 00000000..4199d9af --- /dev/null +++ b/field_vector_config/models/ir_model_field_vector.py @@ -0,0 +1,110 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.tools import sql + +try: + import openai +except ImportError: + openai = None + + +class IrModelFieldVector(models.Model): + """ + Model to store vector configuration for ir.model.fields of type computed_vector + """ + + _name = "ir.model.field.vector" + _description = "Ir Model Field Vector" + _rec_name = "field_id" + + field_id = fields.Many2one( + "ir.model.fields", + ondelete="cascade", + required=True, + domain=[ + ("ttype", "=", "computed_vector"), + ("store", "=", True), + ], + ) + dimensions = fields.Integer() + vector_method = fields.Selection( + selection=lambda self: self._get_vector_methods(), + required=True, + ) + search_prefix = fields.Text( + help="""This prefix will be added to the text before encoding it for search. + It can be used to give specific instructions to the embedding model, + like 'Represent this sentence for searching relevant passages: '""" + ) + openai_url = fields.Char( + string="OpenAI URL", default="https://api.openai.com/v1/embeddings" + ) + openai_model = fields.Char(string="OpenAI Model", default="text-embedding-3-small") + openai_api_key = fields.Char(string="OpenAI API Key", groups="base.group_system") + fastembed_model = fields.Char( + string="FastEmbed Model", default="BAAI/bge-small-en-v1.5" + ) + compute = fields.Text(compute="_compute_compute") + + _sql_constraints = [ + ( + "field_id_uniq", + "unique (field_id)", + "A vector field should be associated to a single ir.model.fields record.", + ) + ] + + @api.depends("field_id") + def _compute_compute(self): + for record in self: + if record.field_id: + record.compute = ( + self.env[record.field_id.model] + .sudo() + ._fields[record.field_id.name] + .compute + ) + else: + record.compute = False + + def compute_values(self): + self.ensure_one() + if not self.compute: + return + getattr(self.env[self.field_id.model].search([]), self.compute)() + + def _get_vector_methods(self): + methods = [] + if openai: + methods.append(("openai", "OpenAI")) + return methods + + def update_column(self): + self.ensure_one() + model = self.env[self.field_id.model].browse() + columns = sql.table_columns(self.env.cr, model._table) + column = columns.get(self.field_id.name) + model._fields[self.field_id.name].update_db_column(model, column) + + def _encode_vector(self, data, is_search=False): + """ + It should allways return a list of vectors, even if the input is a single one. + """ + if is_search and self.search_prefix: + data = self.search_prefix + data + return getattr(self, f"_encode_vector_{self.vector_method}")(data) + + def _encode_vector_openai(self, text): + openai_client = openai.OpenAI(**self.sudo()._get_openai_client_parameters()) + response = openai_client.embeddings.create(model=self.openai_model, input=text) + return [embedding.embedding for embedding in response.data] + + def _get_openai_client_parameters(self): + params = {} + if self.openai_url: + params["base_url"] = self.openai_url + if self.openai_api_key: + params["api_key"] = self.openai_api_key + return params diff --git a/field_vector_config/models/ir_model_fields.py b/field_vector_config/models/ir_model_fields.py new file mode 100644 index 00000000..704af0e6 --- /dev/null +++ b/field_vector_config/models/ir_model_fields.py @@ -0,0 +1,17 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class IrModelFields(models.Model): + _inherit = "ir.model.fields" + + ttype = fields.Selection( + selection_add=[("computed_vector", "Vector with automatic filling")], + ondelete={"computed_vector": "cascade"}, + ) + vector_configuration_ids = fields.One2many( + "ir.model.field.vector", + inverse_name="field_id", + ) diff --git a/field_vector_config/pyproject.toml b/field_vector_config/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/field_vector_config/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/field_vector_config/readme/CONFIGURE.md b/field_vector_config/readme/CONFIGURE.md new file mode 100644 index 00000000..b94aa1cd --- /dev/null +++ b/field_vector_config/readme/CONFIGURE.md @@ -0,0 +1,19 @@ +With this module, we can easily add a configuration on a vector to configure it dynamically on our database. + +```python +from odoo.addons.field_vector_config.fields import ComputedVector + + +class ResPartner(models.Model): + _inherit = "res.partner" + + embedding = ComputedVector(string="Embedding") +``` + +With that, we can go to **Settings / Technical / Database Structure** to add the field manually and configure it. +There you can configure the size of the vector (depends on the method and model), computation information and so on. + +Important notes: + +- If you make a field that is computed, we recommend to create it in a pre_init_hook to avoid the creation and allow the user to configure it properly +- If you change the size of the vector, update the column. It will update the size and do nothing if it has the proper size. diff --git a/field_vector_config/readme/CONTEXT.md b/field_vector_config/readme/CONTEXT.md new file mode 100644 index 00000000..044cc3a7 --- /dev/null +++ b/field_vector_config/readme/CONTEXT.md @@ -0,0 +1,3 @@ +The original field_vector module implements vector configuration, however, it is hard to handle how to create and search them. + +With this module, we added the functionality to do it. \ No newline at end of file diff --git a/field_vector_config/readme/CONTRIBUTORS.md b/field_vector_config/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..2c066ba7 --- /dev/null +++ b/field_vector_config/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Dixmit](https://www.dixmit.com) + - Enric Tobella diff --git a/field_vector_config/readme/DESCRIPTION.md b/field_vector_config/readme/DESCRIPTION.md new file mode 100644 index 00000000..41a25c6c --- /dev/null +++ b/field_vector_config/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module allows to configure vector fields dynamically. +Quite interesting if you want to handle them by using AI. diff --git a/field_vector_config/readme/USAGE.md b/field_vector_config/readme/USAGE.md new file mode 100644 index 00000000..67f8989f --- /dev/null +++ b/field_vector_config/readme/USAGE.md @@ -0,0 +1,24 @@ +After the configuration, we can easily compute and search using this vector. For example: + +```python +from odoo.addons.ai_tool.tools import aitool + + +class ProductProduct(models.Model): + + _inherit = "product.product" + + product_vector = ComputedVector( + compute="_compute_product_vector", + store=True, + ) + + @api.depends("name", "description") + def _compute_product_vector(self): + for record in self: + record.product_vector = record._encode_vector("product_vector", f"{record.name}\n{record.description}\n{record.description_purchase}")[0] + + def _find_vector_product(self, value, limit=5): + records = self.search_vector("product_vector", value, limit=limit) + return [{"id": r.id, "name": r.name, "description": r.description} for r in records] +``` \ No newline at end of file diff --git a/field_vector_config/security/ir.model.access.csv b/field_vector_config/security/ir.model.access.csv new file mode 100644 index 00000000..d1bb33b9 --- /dev/null +++ b/field_vector_config/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ir_model_field_vector,access_ir_model_field_vector,model_ir_model_field_vector,base.group_user,1,1,1,1 diff --git a/field_vector_config/static/description/icon.png b/field_vector_config/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/field_vector_config/static/description/index.html b/field_vector_config/static/description/index.html new file mode 100644 index 00000000..4b2d8336 --- /dev/null +++ b/field_vector_config/static/description/index.html @@ -0,0 +1,489 @@ + + + + + +Field Vector Fill + + + +
+

Field Vector Fill

+ + +

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

+

This module allows to configure vector fields dynamically. Quite +interesting if you want to handle them by using AI.

+

Table of contents

+ +
+

Use Cases / Context

+

The original field_vector module implements vector configuration, +however, it is hard to handle how to create and search them.

+

With this module, we added the functionality to do it.

+
+
+

Configuration

+

With this module, we can easily add a configuration on a vector to +configure it dynamically on our database.

+
+from odoo.addons.field_vector_config.fields import ComputedVector
+
+
+class ResPartner(models.Model):
+    _inherit = "res.partner"
+
+    embedding = ComputedVector(string="Embedding")
+
+

With that, we can go to Settings / Technical / Database Structure to +add the field manually and configure it. There you can configure the +size of the vector (depends on the method and model), computation +information and so on.

+

Important notes:

+
    +
  • If you make a field that is computed, we recommend to create it in a +pre_init_hook to avoid the creation and allow the user to configure it +properly
  • +
  • If you change the size of the vector, update the column. It will +update the size and do nothing if it has the proper size.
  • +
+
+
+

Usage

+

After the configuration, we can easily compute and search using this +vector. For example:

+
+from odoo.addons.ai_tool.tools import aitool
+
+
+class ProductProduct(models.Model):
+
+    _inherit = "product.product"
+
+    product_vector = ComputedVector(
+        compute="_compute_product_vector",
+        store=True,
+    )
+
+    @api.depends("name", "description")
+    def _compute_product_vector(self):
+        for record in self:
+            record.product_vector = record._encode_vector("product_vector", f"{record.name}\n{record.description}\n{record.description_purchase}")[0]
+
+    def _find_vector_product(self, value, limit=5):
+        records = self.search_vector("product_vector", value, limit=limit)
+        return [{"id": r.id, "name": r.name, "description": r.description} for r in records]
+
+
+
+

Bug Tracker

+

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

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Dixmit
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/ai project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/field_vector_config/tests/__init__.py b/field_vector_config/tests/__init__.py new file mode 100644 index 00000000..6e5e6925 --- /dev/null +++ b/field_vector_config/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_vector +from . import test_vector_compute diff --git a/field_vector_config/tests/fake_models.py b/field_vector_config/tests/fake_models.py new file mode 100644 index 00000000..126cca6d --- /dev/null +++ b/field_vector_config/tests/fake_models.py @@ -0,0 +1,15 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + +from odoo.addons.field_vector_config.fields import ComputedVector + + +class VectorFakeModel(models.Model): + _name = "vector.fake.model" + _description = "Fake model to test vector configuration" + + name = fields.Char() + partner_id = fields.Many2one("res.partner") + description_vector = ComputedVector(dimensions=10) diff --git a/field_vector_config/tests/fake_models_compute.py b/field_vector_config/tests/fake_models_compute.py new file mode 100644 index 00000000..bcdfb833 --- /dev/null +++ b/field_vector_config/tests/fake_models_compute.py @@ -0,0 +1,24 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + +from odoo.addons.field_vector_config.fields import ComputedVector + + +class VectorFakeModel(models.Model): + _name = "vector.fake.model" + _description = "Fake model to test vector configuration" + + name = fields.Char() + partner_id = fields.Many2one("res.partner") + description_vector = ComputedVector( + compute="_compute_description_vector", store=True + ) + + @api.depends("name") + def _compute_description_vector(self): + for record in self: + record.description_vector = record._encode_vector( + "description_vector", record.name or "" + )[0] diff --git a/field_vector_config/tests/test_vector.py b/field_vector_config/tests/test_vector.py new file mode 100644 index 00000000..726bb6e2 --- /dev/null +++ b/field_vector_config/tests/test_vector.py @@ -0,0 +1,347 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest import mock + +from odoo_test_helper import FakeModelLoader + +from odoo.exceptions import UserError +from odoo.tests import TransactionCase + +from odoo.addons.field_vector.fields import VectorValue + + +class TestVector(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Load fake models ->/ + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .fake_models import VectorFakeModel + + cls.loader.update_registry((VectorFakeModel,)) + + cls.addClassCleanup(cls.loader.restore_registry) + + cls.vector_model = cls.env["vector.fake.model"] + cls.partner_01 = cls.env["res.partner"].create({"name": "Partner 01"}) + cls.partner_02 = cls.env["res.partner"].create({"name": "Partner 02"}) + + def test_vector(self): + vector_field = self.vector_model._fields["description_vector"] + self.assertEqual( + 10, + vector_field.get_current_vector_size( + self.env.cr, self.vector_model._table, vector_field.name + ), + ) + self.assertEqual(10, vector_field.vector_dimensions(self.vector_model)) + field_config = self.env["ir.model.field.vector"].create( + { + "field_id": self.env["ir.model.fields"] + .search( + [ + ("model", "=", "vector.fake.model"), + ("name", "=", "description_vector"), + ], + limit=1, + ) + .id, + "dimensions": 20, + "vector_method": "openai", + } + ) + self.assertEqual( + 10, + vector_field.get_current_vector_size( + self.env.cr, self.vector_model._table, vector_field.name + ), + ) + self.assertEqual(20, vector_field.vector_dimensions(self.vector_model)) + field_config.update_column() + self.assertEqual( + 20, + vector_field.get_current_vector_size( + self.env.cr, self.vector_model._table, vector_field.name + ), + ) + self.assertEqual(20, vector_field.vector_dimensions(self.vector_model)) + + def test_vector_encoding(self): + field_config = self.env["ir.model.field.vector"].create( + { + "field_id": self.env["ir.model.fields"] + .search( + [ + ("model", "=", "vector.fake.model"), + ("name", "=", "description_vector"), + ], + limit=1, + ) + .id, + "dimensions": 20, + "vector_method": "openai", + } + ) + field_config.update_column() + record_01 = self.vector_model.create({"name": "Test"}) + with mock.patch("openai.OpenAI") as mock_openai: + mock_embedding = mock.MagicMock() + mock_embedding.embedding = [0.5] * 20 + + mock_response = mock.MagicMock() + mock_response.data = [mock_embedding] + + mock_openai.return_value.embeddings.create.return_value = mock_response + record_01.description_vector = record_01._encode_vector( + "description_vector", "MY OWN QUERY" + )[0] + self.assertEqual(record_01.description_vector, VectorValue([0.5] * 20)) + record_02 = self.vector_model.create({"name": "Test"}) + with mock.patch("openai.OpenAI") as mock_openai: + mock_embedding = mock.MagicMock() + mock_embedding.embedding = [0.2] * 20 + + mock_response = mock.MagicMock() + mock_response.data = [mock_embedding] + + mock_openai.return_value.embeddings.create.return_value = mock_response + record_02.description_vector = record_02._encode_vector( + "description_vector", "MY OWN QUERY" + )[0] + self.assertEqual(record_02.description_vector, VectorValue([0.2] * 20)) + record_03 = self.vector_model.create({"name": "Test"}) + with mock.patch("openai.OpenAI") as mock_openai: + mock_embedding = mock.MagicMock() + mock_embedding.embedding = [0.6] * 20 + + mock_response = mock.MagicMock() + mock_response.data = [mock_embedding] + + mock_openai.return_value.embeddings.create.return_value = mock_response + record_03.description_vector = record_03._encode_vector( + "description_vector", "MY OWN QUERY" + )[0] + self.assertEqual(record_03.description_vector, VectorValue([0.6] * 20)) + + def test_search(self): + field_config = self.env["ir.model.field.vector"].create( + { + "field_id": self.env["ir.model.fields"] + .search( + [ + ("model", "=", "vector.fake.model"), + ("name", "=", "description_vector"), + ], + limit=1, + ) + .id, + "dimensions": 20, + "vector_method": "openai", + } + ) + field_config.update_column() + record_01 = self.vector_model.create( + { + "name": "Test", + "description_vector": VectorValue([0.2] * 20), + "partner_id": self.partner_01.id, + } + ) + record_02 = self.vector_model.create( + { + "name": "Test", + "description_vector": VectorValue([0.5] * 20), + "partner_id": self.partner_02.id, + } + ) + record_03 = self.vector_model.create( + { + "name": "Test", + "description_vector": VectorValue([0.6] * 20), + "partner_id": self.partner_01.id, + } + ) + with mock.patch("openai.OpenAI") as mock_openai: + mock_embedding = mock.MagicMock() + mock_embedding.embedding = [0.45] * 20 + + mock_response = mock.MagicMock() + mock_response.data = [mock_embedding] + + mock_openai.return_value.embeddings.create.return_value = mock_response + results = self.vector_model.search_vector( + "description_vector", "MY OWN QUERY" + ) + self.assertEqual(len(results), 3) + self.assertEqual(results[0].id, record_02.id) + self.assertEqual(results[1].id, record_03.id) + self.assertEqual(results[2].id, record_01.id) + with mock.patch("openai.OpenAI") as mock_openai: + mock_embedding = mock.MagicMock() + mock_embedding.embedding = [0.45] * 20 + + mock_response = mock.MagicMock() + mock_response.data = [mock_embedding] + + mock_openai.return_value.embeddings.create.return_value = mock_response + results = self.vector_model.search_vector( + "description_vector", + "MY OWN QUERY", + domain=[("partner_id", "=", self.partner_01.id)], + ) + self.assertEqual(len(results), 2) + self.assertEqual(results[0].id, record_03.id) + self.assertEqual(results[1].id, record_01.id) + + def test_search_aggregated(self): + field_config = self.env["ir.model.field.vector"].create( + { + "field_id": self.env["ir.model.fields"] + .search( + [ + ("model", "=", "vector.fake.model"), + ("name", "=", "description_vector"), + ], + limit=1, + ) + .id, + "dimensions": 20, + "vector_method": "openai", + } + ) + field_config.update_column() + self.vector_model.create( + [ + { + "name": "Test", + "description_vector": VectorValue([0.2] * 20), + "partner_id": self.partner_01.id, + }, + { + "name": "Test", + "description_vector": VectorValue([0.5] * 20), + "partner_id": self.partner_02.id, + }, + { + "name": "Test", + "description_vector": VectorValue([0.6] * 20), + "partner_id": self.partner_01.id, + }, + ] + ) + with mock.patch("openai.OpenAI") as mock_openai: + mock_embedding = mock.MagicMock() + mock_embedding.embedding = [0.56] * 20 + + mock_response = mock.MagicMock() + mock_response.data = [mock_embedding] + + mock_openai.return_value.embeddings.create.return_value = mock_response + results = self.vector_model.search_vector_grouped( + "description_vector", "MY OWN QUERY", "partner_id" + ) + self.assertEqual(len(results), 2) + self.assertEqual(results[0].id, self.partner_01.id) + self.assertEqual(results[1].id, self.partner_02.id) + with mock.patch("openai.OpenAI") as mock_openai: + mock_embedding = mock.MagicMock() + mock_embedding.embedding = [0.53] * 20 + + mock_response = mock.MagicMock() + mock_response.data = [mock_embedding] + + mock_openai.return_value.embeddings.create.return_value = mock_response + results = self.vector_model.search_vector_grouped( + "description_vector", "MY OWN QUERY", "partner_id" + ) + self.assertEqual(len(results), 2) + self.assertEqual(results[0].id, self.partner_02.id) + self.assertEqual(results[1].id, self.partner_01.id) + + def test_aggregated_wrong_field(self): + field_config = self.env["ir.model.field.vector"].create( + { + "field_id": self.env["ir.model.fields"] + .search( + [ + ("model", "=", "vector.fake.model"), + ("name", "=", "description_vector"), + ], + limit=1, + ) + .id, + "dimensions": 20, + "vector_method": "openai", + } + ) + field_config.update_column() + with self.assertRaises(UserError): + self.vector_model.search_vector_grouped( + "description_vector", "MY OWN QUERY", "name" + ) + + def test_aggregated_wrong_base_field(self): + field_config = self.env["ir.model.field.vector"].create( + { + "field_id": self.env["ir.model.fields"] + .search( + [ + ("model", "=", "vector.fake.model"), + ("name", "=", "description_vector"), + ], + limit=1, + ) + .id, + "dimensions": 20, + "vector_method": "openai", + } + ) + field_config.update_column() + with self.assertRaises(UserError): + self.vector_model.search_vector_grouped( + "name", "MY OWN QUERY", "partner_id" + ) + + def test_wrong_base_field(self): + field_config = self.env["ir.model.field.vector"].create( + { + "field_id": self.env["ir.model.fields"] + .search( + [ + ("model", "=", "vector.fake.model"), + ("name", "=", "description_vector"), + ], + limit=1, + ) + .id, + "dimensions": 20, + "vector_method": "openai", + } + ) + field_config.update_column() + with self.assertRaises(UserError): + self.vector_model.search_vector( + "name", + "MY OWN QUERY", + ) + + def test_compute(self): + field_config = self.env["ir.model.field.vector"].create( + { + "field_id": self.env["ir.model.fields"] + .search( + [ + ("model", "=", "vector.fake.model"), + ("name", "=", "description_vector"), + ], + limit=1, + ) + .id, + "dimensions": 20, + "vector_method": "openai", + } + ) + field_config.update_column() + self.assertFalse(field_config.compute) diff --git a/field_vector_config/tests/test_vector_compute.py b/field_vector_config/tests/test_vector_compute.py new file mode 100644 index 00000000..a0a935b2 --- /dev/null +++ b/field_vector_config/tests/test_vector_compute.py @@ -0,0 +1,97 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest import mock + +from odoo_test_helper import FakeModelLoader + +from odoo.tests import TransactionCase + +from odoo.addons.field_vector.fields import VectorValue + + +class TestVectorCompute(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Load fake models ->/ + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .fake_models_compute import VectorFakeModel + + cls.loader.update_registry((VectorFakeModel,)) + + cls.addClassCleanup(cls.loader.restore_registry) + + cls.vector_model = cls.env["vector.fake.model"] + + def test_compute(self): + field_config = self.env["ir.model.field.vector"].create( + { + "field_id": self.env["ir.model.fields"] + .search( + [ + ("model", "=", "vector.fake.model"), + ("name", "=", "description_vector"), + ], + limit=1, + ) + .id, + "dimensions": 20, + "vector_method": "openai", + } + ) + field_config.update_column() + self.assertTrue(field_config.compute) + with mock.patch("openai.OpenAI") as mock_openai: + mock_embedding = mock.MagicMock() + mock_embedding.embedding = [0.5] * 20 + + mock_response = mock.MagicMock() + mock_response.data = [mock_embedding] + + mock_openai.return_value.embeddings.create.return_value = mock_response + records = self.env["vector.fake.model"].create( + [ + { + "name": "Test_01", + }, + { + "name": "Test_02", + }, + ] + ) + records.flush_recordset() + self.assertEqual( + VectorValue([0.5] * 20), + self.env["vector.fake.model"] + .search([("name", "=", "Test_01")], limit=1) + .description_vector, + ) + self.assertEqual( + VectorValue([0.5] * 20), + self.env["vector.fake.model"] + .search([("name", "=", "Test_02")], limit=1) + .description_vector, + ) + with mock.patch("openai.OpenAI") as mock_openai: + mock_embedding = mock.MagicMock() + mock_embedding.embedding = [0.3] * 20 + + mock_response = mock.MagicMock() + mock_response.data = [mock_embedding] + mock_openai.return_value.embeddings.create.return_value = mock_response + field_config.compute_values() + records.flush_recordset() + self.assertEqual( + VectorValue([0.3] * 20), + self.env["vector.fake.model"] + .search([("name", "=", "Test_01")], limit=1) + .description_vector, + ) + self.assertEqual( + VectorValue([0.3] * 20), + self.env["vector.fake.model"] + .search([("name", "=", "Test_02")], limit=1) + .description_vector, + ) diff --git a/field_vector_config/views/ir_model_field_vector.xml b/field_vector_config/views/ir_model_field_vector.xml new file mode 100644 index 00000000..b9e921ee --- /dev/null +++ b/field_vector_config/views/ir_model_field_vector.xml @@ -0,0 +1,91 @@ + + + + + ir.model.field.vector + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + ir.model.field.vector + + + + + + + + + + ir.model.field.vector + + + + + + + + + + Ir Model Field Vector + + ir.model.field.vector + list,form + [] + {} + + + + Field Vector Config + + + + +
diff --git a/test-requirements.txt b/test-requirements.txt index 66bc2cba..19495381 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +1,2 @@ odoo_test_helper +openai From e91c2cd4a101364a35ab343db8166d61d18c89b3 Mon Sep 17 00:00:00 2001 From: Enric Tobella Date: Mon, 6 Jul 2026 16:28:23 +0200 Subject: [PATCH 3/3] [MIG] field_vector_config: Migration to 16.0 --- field_vector_config/README.rst | 16 +++-- field_vector_config/__manifest__.py | 2 +- field_vector_config/models/base.py | 64 ++++++++++++------- .../models/ir_model_field_vector.py | 12 ++++ .../static/description/index.html | 34 ++++++---- .../views/ir_model_field_vector.xml | 10 +-- .../odoo/addons/field_vector_config | 1 + setup/field_vector_config/setup.py | 6 ++ 8 files changed, 96 insertions(+), 49 deletions(-) create mode 120000 setup/field_vector_config/odoo/addons/field_vector_config create mode 100644 setup/field_vector_config/setup.py diff --git a/field_vector_config/README.rst b/field_vector_config/README.rst index 8765b097..2ef252d6 100644 --- a/field_vector_config/README.rst +++ b/field_vector_config/README.rst @@ -1,3 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + ================= Field Vector Fill ================= @@ -13,17 +17,17 @@ Field Vector Fill .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fai-lightgray.png?logo=github - :target: https://github.com/OCA/ai/tree/18.0/field_vector_config + :target: https://github.com/OCA/ai/tree/16.0/field_vector_config :alt: OCA/ai .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/ai-18-0/ai-18-0-field_vector_config + :target: https://translation.odoo-community.org/projects/ai-16-0/ai-16-0-field_vector_config :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=18.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=16.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -108,7 +112,7 @@ Bug Tracker Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -140,6 +144,6 @@ OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use. -This module is part of the `OCA/ai `_ project on GitHub. +This module is part of the `OCA/ai `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/field_vector_config/__manifest__.py b/field_vector_config/__manifest__.py index bf548d10..4e464dd6 100644 --- a/field_vector_config/__manifest__.py +++ b/field_vector_config/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Field Vector Fill", "summary": """Autogenerate vectors and add search functions""", - "version": "18.0.1.0.0", + "version": "16.0.1.0.0", "license": "AGPL-3", "author": "Dixmit,Odoo Community Association (OCA)", "website": "https://github.com/OCA/ai", diff --git a/field_vector_config/models/base.py b/field_vector_config/models/base.py index 51a04f8d..547cf32f 100644 --- a/field_vector_config/models/base.py +++ b/field_vector_config/models/base.py @@ -12,32 +12,38 @@ class Base(models.AbstractModel): _inherit = "base" @api.model - @api.readonly @api.returns("self") def search_vector(self, field_name, data, domain=None, limit=5, minim=0.5): if domain is None: domain = [] if not isinstance(self._fields[field_name], ComputedVector): raise UserError(f"Field {field_name} is not a computed_vector field") - to_search = self._encode_vector(field_name, data, is_search=True)[0] + to_search = ( + "[" + + ",".join( + map(str, self._encode_vector(field_name, data, is_search=True)[0]) + ) + + "]" + ) query = self._search(domain, limit=limit) distance = sql.SQL( - "%s <=> %s::vector", - self._field_to_sql(self._table, field_name, query), - to_search, - ) + "%s <=> '%s'::vector" + % ( + sql.Identifier(self._table, field_name).as_string(self.env.cr._obj), + to_search, + ) + ).as_string(self.env.cr._obj) sql_terms = [ - self._field_to_sql(self._table, "id", "query"), - sql.SQL("%s as distance", distance), + sql.Identifier(self._table, "id").as_string(self.env.cr._obj), + sql.SQL("%s as distance" % distance).as_string(self.env.cr._obj), ] query.order = "distance ASC" if minim: - query.add_where(sql.SQL("%s < %s", distance, minim)) - self.env.cr.execute(query.select(*sql_terms)) + query.add_where("%s < %s" % (distance, minim)) + self.env.cr.execute(*query.select(*sql_terms)) return self.browse([row[0] for row in self.env.cr.fetchall()]) @api.model - @api.readonly @api.returns("self") def search_vector_grouped( self, field_name, data, final_field, domain=None, limit=5, minim=0.5 @@ -48,22 +54,34 @@ def search_vector_grouped( raise UserError(f"Field {field_name} is not a computed_vector field") if not isinstance(self._fields[final_field], fields.Many2one): raise UserError(f"Field {final_field} is not a Many2one field") - to_search = self._encode_vector(field_name, data, is_search=True)[0] - query = self._search(domain, limit=limit) - distance = sql.SQL( - "MIN(%s <=> %s::vector)", - self._field_to_sql(self._table, field_name, query), - to_search, + to_search = ( + "[" + + ",".join( + map(str, self._encode_vector(field_name, data, is_search=True)[0]) + ) + + "]" ) + query = self._search(domain) + query.order = "" + distance = sql.SQL( + "MIN(%s <=> '%s'::vector)" + % ( + sql.Identifier(self._table, field_name).as_string(self.env.cr._obj), + to_search, + ) + ).as_string(self.env.cr._obj) sql_terms = [ - self._field_to_sql(self._table, final_field, "query"), - sql.SQL("%s as distance", distance), + sql.Identifier(self._table, final_field).as_string(self.env.cr._obj), + sql.SQL("%s as distance" % distance).as_string(self.env.cr._obj), ] - query.order = "distance ASC" - query.groupby = self._field_to_sql(self._table, final_field, query) + query_str, params = query.select(*sql_terms) + query_str += " GROUP BY %s" % sql_terms[0] if minim: - query.having = sql.SQL("%s < %s", distance, minim) - self.env.cr.execute(query.select(*sql_terms)) + query_str += " HAVING %s < %s" % (distance, minim) + query_str += " ORDER BY distance ASC" + if limit: + query_str += f" LIMIT {limit}" + self.env.cr.execute(query_str, params) rows = self.env.cr.fetchall() return self[final_field].browse([row[0] for row in rows]) diff --git a/field_vector_config/models/ir_model_field_vector.py b/field_vector_config/models/ir_model_field_vector.py index 4199d9af..865087d6 100644 --- a/field_vector_config/models/ir_model_field_vector.py +++ b/field_vector_config/models/ir_model_field_vector.py @@ -8,6 +8,10 @@ import openai except ImportError: openai = None +try: + import fastembed +except ImportError: + fastembed = None class IrModelFieldVector(models.Model): @@ -79,6 +83,8 @@ def _get_vector_methods(self): methods = [] if openai: methods.append(("openai", "OpenAI")) + if fastembed: + methods.append(("fastembed", "FastEmbed")) return methods def update_column(self): @@ -108,3 +114,9 @@ def _get_openai_client_parameters(self): if self.openai_api_key: params["api_key"] = self.openai_api_key return params + + def _encode_vector_fastembed(self, text): + model = fastembed.TextEmbedding(self.fastembed_model) + + embeddings = list(model.embed([text.lower().strip()])) + return embeddings diff --git a/field_vector_config/static/description/index.html b/field_vector_config/static/description/index.html index 4b2d8336..54f954c7 100644 --- a/field_vector_config/static/description/index.html +++ b/field_vector_config/static/description/index.html @@ -3,7 +3,7 @@ -Field Vector Fill +README.rst -
-

Field Vector Fill

+
+ + +Odoo Community Association + +
+

Field Vector Fill

-

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

+

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

This module allows to configure vector fields dynamically. Quite interesting if you want to handle them by using AI.

Table of contents

@@ -388,13 +393,13 @@

Field Vector Fill

-

Use Cases / Context

+

Use Cases / Context

The original field_vector module implements vector configuration, however, it is hard to handle how to create and search them.

With this module, we added the functionality to do it.

-

Configuration

+

Configuration

With this module, we can easily add a configuration on a vector to configure it dynamically on our database.

@@ -420,7 +425,7 @@ 

Configuration

-

Usage

+

Usage

After the configuration, we can easily compute and search using this vector. For example:

@@ -447,23 +452,23 @@ 

Usage

-

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 -feedback.

+feedback.

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

-

Credits

+

Credits

-

Authors

+

Authors

  • Dixmit
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -480,10 +485,11 @@

Maintainers

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

-

This module is part of the OCA/ai project on GitHub.

+

This module is part of the OCA/ai project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
diff --git a/field_vector_config/views/ir_model_field_vector.xml b/field_vector_config/views/ir_model_field_vector.xml index b9e921ee..b6e0f91b 100644 --- a/field_vector_config/views/ir_model_field_vector.xml +++ b/field_vector_config/views/ir_model_field_vector.xml @@ -18,7 +18,7 @@ type="object" string="Compute Values" class="oe_highlight" - invisible="not compute" + attrs="{'invisible': [('compute', '=', False)]}" /> @@ -31,7 +31,7 @@ @@ -41,7 +41,7 @@ @@ -66,10 +66,10 @@ ir.model.field.vector - + - + diff --git a/setup/field_vector_config/odoo/addons/field_vector_config b/setup/field_vector_config/odoo/addons/field_vector_config new file mode 120000 index 00000000..49b3b7b4 --- /dev/null +++ b/setup/field_vector_config/odoo/addons/field_vector_config @@ -0,0 +1 @@ +../../../../field_vector_config \ No newline at end of file diff --git a/setup/field_vector_config/setup.py b/setup/field_vector_config/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/field_vector_config/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)