Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions ai_oca_bridge/README.rst
Original file line number Diff line number Diff line change
@@ -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
=============
Expand All @@ -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
Expand Down
42 changes: 42 additions & 0 deletions ai_oca_bridge/examples/filter_spam_tickets.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="tag_helpdesk_spam" model="helpdesk.ticket.tag">
<field name="name">Spam</field>
<field name="color">1</field>
</record>

<record id="action_mark_ticket_as_spam" model="ir.actions.server">
<field name="name">AI Action: Mark Ticket as Spam</field>
<field name="model_id" ref="helpdesk_mgmt.model_helpdesk_ticket" />
<field name="state">code</field>
<field name="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"🤖 &lt;b&gt;SPAM DETECTED&lt;/b&gt;&lt;br/&gt;Reason: {reason} (Confidence: {confidence})"
ticket.message_post(body=msg_body)
</field>
</record>

<record id="ai_bridge_helpdesk_ticket_spam_identify" model="ai.bridge">
<field name="name">Helpdesk Ticket Spam Identification</field>
<field name="model_id" ref="helpdesk_mgmt.model_helpdesk_ticket" />
<field name="usage">ai_thread_create</field>
<field name="result_kind">async</field>
<field name="result_type">server_action</field>
<field name="server_action_id" ref="ai_oca_bridge.action_mark_ticket_as_spam" />
<field
name="field_ids"
eval="[(6, 0, [ref('helpdesk_mgmt.field_helpdesk_ticket__id'), ref('helpdesk_mgmt.field_helpdesk_ticket__display_name'), ref('helpdesk_mgmt.field_helpdesk_ticket__description')])]"
/>
</record>
</odoo>
147 changes: 147 additions & 0 deletions ai_oca_bridge/examples/filter_spam_tickets_n8n.json
Original file line number Diff line number Diff line change
@@ -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": []
}
32 changes: 32 additions & 0 deletions ai_oca_bridge/models/ai_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions ai_oca_bridge/models/ai_bridge_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 10 additions & 2 deletions ai_oca_bridge/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -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:
Expand Down
Loading
Loading