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/models/ai_bridge.py b/ai_oca_bridge/models/ai_bridge.py
index 5c13407..460ff07 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,27 @@ 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,
+ )
+ 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
@@ -189,6 +211,16 @@ 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:
+ 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/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/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
-
-
-
-
-
-
AI OCA Bridge
-

+

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
-
+
Right now, there are 2 different approaches for AI integration with
Odoo:
@@ -430,17 +425,17 @@
be used as Bridge with AI systems.
-
+
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.
-
+
On the external system, you will receive a POST payload. The data
included will be the following:
-
+
- _odoo: Standard data to identify the Odoo Database
- _model: Model of the related object
@@ -449,16 +444,16 @@
-
+
Adds a new item called record with all the fields.
-
+
Adds all the fields directly on the payload. It will be removed on 17.0.
-
+
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 @@
-
+
With the answers of the system we expect to do something about it. We
have the following options:
-
+
In this case, the result will do nothing
-
+
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.
-
+
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 @@
-
+
Use the bolt widget in the chatter to execute the different AI options.
The options will be filtered according to the configuration.
-
+
- Define examples to use and import
- Allow child fields. Right now, only first level fields are accepted.
@@ -513,7 +508,7 @@
-
+
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 @@
Do not contact contributors directly about support or help with technical issues.
-