From 2731fb304c94b5245590dbf3e0ce3166dd16f774 Mon Sep 17 00:00:00 2001 From: thienvh Date: Tue, 16 Dec 2025 09:36:33 +0700 Subject: [PATCH 1/3] [IMP] ai_oca_bridge: support server_action result_type --- ai_oca_bridge/models/ai_bridge.py | 13 ++++++++ ai_oca_bridge/models/ai_bridge_execution.py | 24 ++++++++++++++ ai_oca_bridge/tests/test_bridge.py | 36 +++++++++++++++++++++ ai_oca_bridge/views/ai_bridge.xml | 5 +++ 4 files changed, 78 insertions(+) diff --git a/ai_oca_bridge/models/ai_bridge.py b/ai_oca_bridge/models/ai_bridge.py index 5c13407..5a4536a 100644 --- a/ai_oca_bridge/models/ai_bridge.py +++ b/ai_oca_bridge/models/ai_bridge.py @@ -63,6 +63,7 @@ class AiBridge(models.Model): ("none", "No processing"), ("message", "Post a Message"), ("action", "Action"), + ("server_action", "Run a Server Action"), ], required=True, default="none", @@ -124,6 +125,13 @@ class AiBridge(models.Model): help="The model to which this bridge is associated.", ) model_required = fields.Boolean(compute="_compute_model_fields") + server_action_id = fields.Many2one( + "ir.actions.server", + string="Server Action", + compute="_compute_server_action_id", + readonly=False, + store=True, + ) ####################################### # Payload type 'record' specific fields @@ -189,6 +197,11 @@ def _compute_field_ids(self): for record in self: record.field_ids = False + @api.depends("result_type") + def _compute_server_action_id(self): + for record in self: + record.server_action_id = False + @api.depends("field_ids", "model_id", "payload_type") def _compute_sample_payload(self): for record in self: diff --git a/ai_oca_bridge/models/ai_bridge_execution.py b/ai_oca_bridge/models/ai_bridge_execution.py index 1e2e342..12eec66 100644 --- a/ai_oca_bridge/models/ai_bridge_execution.py +++ b/ai_oca_bridge/models/ai_bridge_execution.py @@ -216,6 +216,30 @@ def _process_response_action(self, response): return {"action": action} return {} + def _process_response_server_action(self, response): + action = self.ai_bridge_id.server_action_id + + if not action: + return {"warning": "The Server Action to run has not been defined."} + + model_name = self.sudo().model_id.model + if not model_name: + return {"warning": "No model found for this execution."} + + ctx = dict( + self.env.context, + **{ + "active_id": self.res_id, + "active_ids": [self.res_id], + "active_model": model_name, + "ai_response": response, + }, + ) + + action.with_context(**ctx).run() + + return {"status": "success", "action": action.name} + def _get_channel(self): if self.model_id and self.res_id: return self.env[self.model_id.model].browse(self.res_id) diff --git a/ai_oca_bridge/tests/test_bridge.py b/ai_oca_bridge/tests/test_bridge.py index 7a06239..2e6b6af 100644 --- a/ai_oca_bridge/tests/test_bridge.py +++ b/ai_oca_bridge/tests/test_bridge.py @@ -365,3 +365,39 @@ def test_bridge_execute_computed_fields(self): self.assertEqual( execution.payload["_id"], json.loads(execution.payload_txt)["_id"] ) + + def test_bridge_result_server_action_success(self): + # Create a server action and give it an external id + server_action = self.env["ir.actions.server"].create( + { + "name": "Test SA", + "model_id": self.env.ref("base.model_res_partner").id, + "state": "code", + "code": "records.ensure_one()\nresult = True", + } + ) + # Configure bridge to use server_action immediate + self.bridge.write( + { + "result_type": "server_action", + "result_kind": "immediate", + "server_action_id": server_action.id, + } + ) + self.env["ir.model.data"].create( + { + "name": "test_server_action_bridge", + "module": "ai_oca_bridge", + "model": "ir.actions.server", + "res_id": server_action.id, + "noupdate": True, + } + ) + with mock.patch("requests.post") as mock_post: + mock_post.return_value = mock.Mock( + status_code=200, + json=lambda: {"body": "My message"}, + ) + result = self.bridge.execute_ai_bridge(self.partner._name, self.partner.id) + mock_post.assert_called_once() + self.assertEqual(result, {"status": "success", "action": server_action.name}) diff --git a/ai_oca_bridge/views/ai_bridge.xml b/ai_oca_bridge/views/ai_bridge.xml index 2536e4c..74ebe46 100644 --- a/ai_oca_bridge/views/ai_bridge.xml +++ b/ai_oca_bridge/views/ai_bridge.xml @@ -35,6 +35,11 @@ attrs="{'required': [('model_required', '=', 'True')]}" /> + From ff65fff7b445c5fc1ed25aa89cfd10701873a9b5 Mon Sep 17 00:00:00 2001 From: thienvh Date: Tue, 16 Dec 2025 09:38:26 +0700 Subject: [PATCH 2/3] [IMP] ai_oca_bridge: support field-based trigger for ai_thread_update --- ai_oca_bridge/models/ai_bridge.py | 19 +++++++++++++++++++ ai_oca_bridge/models/base.py | 12 ++++++++++-- ai_oca_bridge/tests/fake_models.py | 1 + ai_oca_bridge/tests/test_mixin.py | 26 ++++++++++++++++++++++++++ ai_oca_bridge/views/ai_bridge.xml | 6 ++++++ 5 files changed, 62 insertions(+), 2 deletions(-) diff --git a/ai_oca_bridge/models/ai_bridge.py b/ai_oca_bridge/models/ai_bridge.py index 5a4536a..460ff07 100644 --- a/ai_oca_bridge/models/ai_bridge.py +++ b/ai_oca_bridge/models/ai_bridge.py @@ -132,6 +132,20 @@ class AiBridge(models.Model): readonly=False, store=True, ) + trigger_field_ids = fields.Many2many( + "ir.model.fields", + relation="ai_bridge_trigger_ir_model_fields_rel", + column1="bridge_id", + column2="field_id", + compute="_compute_trigger_field_ids", + store=True, + readonly=False, + string="Trigger Fields", + help=( + "When set and usage is 'On Record Updated', the bridge will only " + "trigger if any of these fields are present in the write values." + ), + ) ####################################### # Payload type 'record' specific fields @@ -197,6 +211,11 @@ def _compute_field_ids(self): for record in self: record.field_ids = False + @api.depends("model_id") + def _compute_trigger_field_ids(self): + for record in self: + record.trigger_field_ids = False + @api.depends("result_type") def _compute_server_action_id(self): for record in self: diff --git a/ai_oca_bridge/models/base.py b/ai_oca_bridge/models/base.py index c206737..456d3cc 100644 --- a/ai_oca_bridge/models/base.py +++ b/ai_oca_bridge/models/base.py @@ -20,7 +20,7 @@ def create(self, vals_list): def write(self, values): result = super().write(values) - self._execute_ai_bridges_for_records(self, "ai_thread_write") + self._execute_ai_bridges_for_records(self, "ai_thread_write", values=values) return result def unlink(self): @@ -39,7 +39,7 @@ def unlink(self): return result - def _execute_ai_bridges_for_records(self, records, usage): + def _execute_ai_bridges_for_records(self, records, usage, values=None): if not records: return model_id = self.sudo().env["ir.model"]._get_id(records._name) @@ -49,6 +49,14 @@ def _execute_ai_bridges_for_records(self, records, usage): .search([("model_id", "=", model_id), ("usage", "=", usage)]) ) for bridge in bridges: + # If this is a write, and the bridge has trigger fields configured, + # only proceed when at least one of those fields is present in values + if usage == "ai_thread_write" and values is not None: + trigger_fields = bridge.sudo().trigger_field_ids + if trigger_fields: + trigger_names = set(trigger_fields.mapped("name")) + if trigger_names.isdisjoint(values.keys()): + continue for record in records: if bridge._enabled_for(record): try: diff --git a/ai_oca_bridge/tests/fake_models.py b/ai_oca_bridge/tests/fake_models.py index 94ccfc1..6c83df3 100644 --- a/ai_oca_bridge/tests/fake_models.py +++ b/ai_oca_bridge/tests/fake_models.py @@ -6,3 +6,4 @@ class BridgeTest(models.Model): _description = "Test Model for AI Bridge" name = fields.Char() + description = fields.Char() diff --git a/ai_oca_bridge/tests/test_mixin.py b/ai_oca_bridge/tests/test_mixin.py index 58e9d0a..0346875 100644 --- a/ai_oca_bridge/tests/test_mixin.py +++ b/ai_oca_bridge/tests/test_mixin.py @@ -281,3 +281,29 @@ def test_execute_ai_bridges_empty_records(self): [("ai_bridge_id", "=", self.bridge.id)] ) self.assertEqual(execution_count, 0) + + def test_trigger_field_ids_write_filters(self): + self.bridge.write({"usage": "ai_thread_write"}) + # Only trigger when the 'name' field is present in values + name_field = self.env["ir.model.fields"].search( + [("model", "=", "bridge.test"), ("name", "=", "name")], limit=1 + ) + self.assertTrue(name_field, "Name field should exist for bridge.test") + self.bridge.trigger_field_ids = [(6, 0, [name_field.id])] + + record = self.env["bridge.test"].create({"name": "N", "description": "d"}) + with mock.patch("requests.post") as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {"result": "ok"} + + # Write on non-configured field -> should NOT trigger + record.write({"description": "d2"}) + self.assertEqual(mock_post.call_count, 0) + + # Write on configured field -> should trigger once + record.write({"name": "N2"}) + self.assertEqual(mock_post.call_count, 1) + + # Write on both -> still triggers (presence of 'name') + record.write({"name": "N3", "description": "d3"}) + self.assertEqual(mock_post.call_count, 2) diff --git a/ai_oca_bridge/views/ai_bridge.xml b/ai_oca_bridge/views/ai_bridge.xml index 74ebe46..f9f5d2d 100644 --- a/ai_oca_bridge/views/ai_bridge.xml +++ b/ai_oca_bridge/views/ai_bridge.xml @@ -40,6 +40,12 @@ domain="[('model_id', '=', model_id)]" attrs="{'invisible': [('result_type', '!=', 'server_action')]}" /> + From 8d1e49b72d7b1375a81f128b0110667d2511be22 Mon Sep 17 00:00:00 2001 From: thienvh Date: Wed, 17 Dec 2025 13:07:38 +0700 Subject: [PATCH 3/3] [IMP] ai_oca_bridge: add examples data for AI bridge configuration with server_action --- ai_oca_bridge/README.rst | 6 +- .../examples/filter_spam_tickets.xml | 42 +++++ .../examples/filter_spam_tickets_n8n.json | 147 ++++++++++++++++++ ai_oca_bridge/static/description/index.html | 50 +++--- 4 files changed, 212 insertions(+), 33 deletions(-) create mode 100644 ai_oca_bridge/examples/filter_spam_tickets.xml create mode 100644 ai_oca_bridge/examples/filter_spam_tickets_n8n.json diff --git a/ai_oca_bridge/README.rst b/ai_oca_bridge/README.rst index dcc16a9..cf5f31d 100644 --- a/ai_oca_bridge/README.rst +++ b/ai_oca_bridge/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ============= AI OCA Bridge ============= @@ -17,7 +13,7 @@ AI OCA Bridge .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |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 diff --git a/ai_oca_bridge/examples/filter_spam_tickets.xml b/ai_oca_bridge/examples/filter_spam_tickets.xml new file mode 100644 index 0000000..fe2842e --- /dev/null +++ b/ai_oca_bridge/examples/filter_spam_tickets.xml @@ -0,0 +1,42 @@ + + + + Spam + 1 + + + + AI Action: Mark Ticket as Spam + + code + +ai_data = env.context.get('ai_response', {}) + +reason = ai_data.get('reason', 'AI System Flagged') +confidence = ai_data.get('confidence', 'N/A') + +spam_tag = env.ref('ai_oca_bridge.tag_helpdesk_spam', raise_if_not_found=False) + +if spam_tag: + for ticket in records: + if spam_tag.id not in ticket.tag_ids.ids: + ticket.write({'tag_ids': [(4, spam_tag.id)]}) + + msg_body = f"🤖 <b>SPAM DETECTED</b><br/>Reason: {reason} (Confidence: {confidence})" + ticket.message_post(body=msg_body) + + + + + Helpdesk Ticket Spam Identification + + ai_thread_create + async + server_action + + + + diff --git a/ai_oca_bridge/examples/filter_spam_tickets_n8n.json b/ai_oca_bridge/examples/filter_spam_tickets_n8n.json new file mode 100644 index 0000000..9908422 --- /dev/null +++ b/ai_oca_bridge/examples/filter_spam_tickets_n8n.json @@ -0,0 +1,147 @@ +{ + "name": "Spam Ticket Identification Flow", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "helpdesk/check-spam/", + "options": { + "rawBody": true + } + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [-1264, 80], + "id": "960d09d1-c2a3-4710-9c9a-07aa0cd3f795", + "name": "Webhook", + "webhookId": "da674dc0-4dc8-4ed6-a51d-59afff4272e2" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "99e8601c-e16b-4df6-9df9-f445ec130db8", + "leftValue": "={{ JSON.parse($json.message.content).is_spam }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [-688, 80], + "id": "982df316-3690-434c-85fa-c0c1da46dfdb", + "name": "If" + }, + { + "parameters": { + "modelId": { + "__rl": true, + "value": "gpt-4o-mini", + "mode": "list", + "cachedResultName": "GPT-4O-MINI" + }, + "messages": { + "values": [ + { + "content": "You are a strict Spam Detection Officer for an Odoo/IT Helpdesk.\n Your task is to analyze the incoming ticket description and determine whether it is spam.\nCRITERIA FOR SPAM — Return true when ANY of these is detected:\n1. SEO, marketing, backlink, or website ranking service offers.\n2. Unrelated service promotions (web design, mobile apps, logo design, copywriting, outsourcing not related to our project).\n3. Suspicious links, shortened URLs without context, or phishing-like messages.\n4. Bulk-generated outreach messages (generic, not referencing Odoo/IT context).\n5. Nonsense text, random characters, or obviously AI-generated gibberish.\n6. Messages unrelated to IT, Odoo, ERP, or our provided services.\nCRITERIA FOR HAM — Return false:\n1. Issues or questions related to Odoo, modules, customization, bugs, deployments, or integration.\n2. Questions about invoices, access, accounts, or contract-related matters.\n3. Complaints, feedback, or feature requests.\n4. Any message that resembles a genuine support request from a human.\nOUTPUT FORMAT:\nStrictly return a JSON object with this structure:\n{\n \"is_spam\": boolean,\n \"reason\": \"Short explanation why this is spam (max 1 sentence)\",\n \"confidence\": \"Estimation from 0% to 100% (e.g. '98%')\"\n}\nExample: {\"is_spam\": true, \"reason\": \"Mentions prohibited drugs (Viagra).\", \"confidence\": \"99%\"}", + "role": "system" + }, + { + "content": "={{ $json.body.record.display_name}} - {{ $json.body.record.description }}" + } + ] + }, + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.openAi", + "typeVersion": 1.8, + "position": [-1056, 80], + "id": "e431b723-d2a4-4d3c-a8ae-420584da691c", + "name": "Message a model", + "credentials": { + "openAiApi": { + "id": "t7lm95Kw7wmoj4xt", + "name": "OpenAi account 2" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "={{$('Webhook').item.json.body._response_url}}", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{$json.message.content}}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [-432, -32], + "id": "a9e87afa-4907-436e-a241-f63c964af12b", + "name": "HTTP Request", + "alwaysOutputData": false + } + ], + "pinData": {}, + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Message a model", + "type": "main", + "index": 0 + } + ] + ] + }, + "Message a model": { + "main": [ + [ + { + "node": "If", + "type": "main", + "index": 0 + } + ] + ] + }, + "If": { + "main": [ + [ + { + "node": "HTTP Request", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "d7f051a5-c962-44b6-87b1-54441763efef", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "95483db1596026047067fce31863479f616aaf310d370d2a94b0e438c3495911" + }, + "id": "fWYne7mmDfzimDWd", + "tags": [] +} diff --git a/ai_oca_bridge/static/description/index.html b/ai_oca_bridge/static/description/index.html index 3d225ab..5e3587e 100644 --- a/ai_oca_bridge/static/description/index.html +++ b/ai_oca_bridge/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +AI OCA Bridge -
+
+

AI OCA Bridge

- - -Odoo Community Association - -
-

AI OCA Bridge

-

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 is used to create a bridge between Odoo and other AI systems like n8n.

Table of contents

@@ -409,7 +404,7 @@

AI OCA Bridge

-

Use Cases / Context

+

Use Cases / Context

Right now, there are 2 different approaches for AI integration with Odoo:

    @@ -430,17 +425,17 @@

    Use Cases / Context

    be used as Bridge with AI systems.

-

Configuration

+

Configuration

As an administrator access AI Bridge\AI Bridge.

Create a new bridge. Define the name, model, url and configuration.

In order to improve the view of the AI configuration, use groups and domain to set better filters.

-

Payload Configuration

+

Payload Configuration

On the external system, you will receive a POST payload. The data included will be the following:

-

General

+

General

  • _odoo: Standard data to identify the Odoo Database
  • _model: Model of the related object
  • @@ -449,16 +444,16 @@

    General

-

Record Payload

+

Record Payload

Adds a new item called record with all the fields.

-

Record Payload (v0)

+

Record Payload (v0)

Adds all the fields directly on the payload. It will be removed on 17.0.

-

Asynchronous and synchronous calls

+

Asynchronous and synchronous calls

The new system allows asynchronous and synchronous calls. Asynchronous calls makes sense when the task to be processed don’t need to be immediate. For example, reviewing an invoice and leave a comment with @@ -473,21 +468,21 @@

Asynchronous and synchronous call automatically on the synchronous call.

-

Result processing

+

Result processing

With the answers of the system we expect to do something about it. We have the following options:

-

No processing

+

No processing

In this case, the result will do nothing

-

Post a Message

+

Post a Message

We will post a message on the original thread of the system. The thread is computed by a function, so it can be overriden in future modules. It expects the keyword arguments of the message_post function.

-

Action

+

Action

It expects to launch an action on the user interface. It only makes sense on synchronous calls.

It expects an action item with the following parameters:

@@ -500,12 +495,12 @@

Action

-

Usage

+

Usage

Use the bolt widget in the chatter to execute the different AI options.

The options will be filtered according to the configuration.

-

Known issues / Roadmap

+

Known issues / Roadmap

  • Define examples to use and import
  • Allow child fields. Right now, only first level fields are accepted.
  • @@ -513,7 +508,7 @@

    Known issues / Roadmap

-

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 @@ -521,15 +516,15 @@

Bug Tracker

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 @@ -559,6 +554,5 @@

Maintainers

-