From 2c7780e1eb9c0d0f30dbfb888ff074f4659607b9 Mon Sep 17 00:00:00 2001 From: nobuQuartile Date: Sat, 4 Jul 2026 07:23:52 +0000 Subject: [PATCH] [6971][ADD] ai_tool_mail_message_search --- ai_tool_mail_message_search/README.rst | 82 ++++ ai_tool_mail_message_search/__init__.py | 1 + ai_tool_mail_message_search/__manifest__.py | 15 + ai_tool_mail_message_search/data/ai_tools.xml | 27 ++ .../models/__init__.py | 1 + .../models/mail_message.py | 137 ++++++ .../readme/CONTRIBUTORS.md | 2 + .../readme/DESCRIPTION.md | 12 + .../static/description/index.html | 433 ++++++++++++++++++ ai_tool_mail_message_search/tests/__init__.py | 1 + .../tests/test_mail_message_search.py | 119 +++++ .../odoo/addons/ai_tool_mail_message_search | 1 + setup/ai_tool_mail_message_search/setup.py | 6 + 13 files changed, 837 insertions(+) create mode 100644 ai_tool_mail_message_search/README.rst create mode 100644 ai_tool_mail_message_search/__init__.py create mode 100644 ai_tool_mail_message_search/__manifest__.py create mode 100644 ai_tool_mail_message_search/data/ai_tools.xml create mode 100644 ai_tool_mail_message_search/models/__init__.py create mode 100644 ai_tool_mail_message_search/models/mail_message.py create mode 100644 ai_tool_mail_message_search/readme/CONTRIBUTORS.md create mode 100644 ai_tool_mail_message_search/readme/DESCRIPTION.md create mode 100644 ai_tool_mail_message_search/static/description/index.html create mode 100644 ai_tool_mail_message_search/tests/__init__.py create mode 100644 ai_tool_mail_message_search/tests/test_mail_message_search.py create mode 120000 setup/ai_tool_mail_message_search/odoo/addons/ai_tool_mail_message_search create mode 100644 setup/ai_tool_mail_message_search/setup.py diff --git a/ai_tool_mail_message_search/README.rst b/ai_tool_mail_message_search/README.rst new file mode 100644 index 0000000..f818367 --- /dev/null +++ b/ai_tool_mail_message_search/README.rst @@ -0,0 +1,82 @@ +============================= +AI Tool - Mail Message Search +============================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:96128387292e73b2bee63660fa232b8feaf2ccc105d10fa67f025eb7d51d9475 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-qrtl%2Fqrtl--oca-lightgray.png?logo=github + :target: https://github.com/qrtl/qrtl-oca/tree/16.0/ai_tool_mail_message_search + :alt: qrtl/qrtl-oca + +|badge1| |badge2| |badge3| + +This module exposes chatter (``mail.message``) search as ``ai_tool`` MCP +tools, allowing AI agents to look up conversation history across any +record. + +Two tools are provided: + +- **search_messages**: searches chatter messages by one or more keywords + (matching subject, body and record name), returning the matching + messages' content plus the model/id of the record they are attached + to, grouped per keyword. +- **get_messages_for_records**: given a list of ``{model, id}`` record + references, returns the full chatter message history logged against + each of those records. + +**Table of contents** + +.. contents:: + :local: + +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 +------- + +* Quartile + +Contributors +------------ + +- `Quartile `__: + + - Toshikimi Shigenobu + +Maintainers +----------- + +.. |maintainer-nobuQuartile| image:: https://github.com/nobuQuartile.png?size=40px + :target: https://github.com/nobuQuartile + :alt: nobuQuartile + +Current maintainer: + +|maintainer-nobuQuartile| + +This module is part of the `qrtl/qrtl-oca `_ project on GitHub. + +You are welcome to contribute. diff --git a/ai_tool_mail_message_search/__init__.py b/ai_tool_mail_message_search/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/ai_tool_mail_message_search/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/ai_tool_mail_message_search/__manifest__.py b/ai_tool_mail_message_search/__manifest__.py new file mode 100644 index 0000000..9e2bb9b --- /dev/null +++ b/ai_tool_mail_message_search/__manifest__.py @@ -0,0 +1,15 @@ +{ + "name": "AI Tool - Mail Message Search", + "summary": "Expose chatter message search/lookup as ai_tool MCP tools", + "version": "16.0.1.0.0", + "website": "https://github.com/OCA/ai", + "license": "AGPL-3", + "author": "Quartile", + "maintainers": ["nobuQuartile"], + "depends": [ + "ai_tool", + ], + "data": [ + "data/ai_tools.xml", + ], +} diff --git a/ai_tool_mail_message_search/data/ai_tools.xml b/ai_tool_mail_message_search/data/ai_tools.xml new file mode 100644 index 0000000..159aa00 --- /dev/null +++ b/ai_tool_mail_message_search/data/ai_tools.xml @@ -0,0 +1,27 @@ + + + + + search_messages + + Search across all chatter messages by one or more keywords (matches + subject, body and record name). Returns, per keyword, the matching + messages' content plus the model/id of the record they are attached to. + + + _ai_search_messages + generic + + + + get_messages_for_records + + Given a list of {model, id} record references, return the full chatter + message history logged against each of those records. + + + _ai_get_messages_for_records + generic + + + diff --git a/ai_tool_mail_message_search/models/__init__.py b/ai_tool_mail_message_search/models/__init__.py new file mode 100644 index 0000000..a2bc21b --- /dev/null +++ b/ai_tool_mail_message_search/models/__init__.py @@ -0,0 +1 @@ +from . import mail_message diff --git a/ai_tool_mail_message_search/models/mail_message.py b/ai_tool_mail_message_search/models/mail_message.py new file mode 100644 index 0000000..cff4dbe --- /dev/null +++ b/ai_tool_mail_message_search/models/mail_message.py @@ -0,0 +1,137 @@ +from odoo import models +from odoo.osv import expression +from odoo.tools import html2plaintext + +from odoo.addons.ai_tool.tools import aitool + +SNIPPET_LEN = 500 +DEFAULT_LIMIT = 20 +# Same field list/word-tokenization approach as qrtlrepos/axls-oca/mail_message_search's +# mail.thread._search_message_search, adapted here to search mail.message directly +# across all models instead of being scoped to one already-known model. +SEARCH_FIELDS = ["record_name", "subject", "body", "email_from", "reply_to"] + + +class MailMessage(models.Model): + _inherit = "mail.message" + + @aitool( + input_schema={ + "terms": {"type": "array", "items": {"type": "string"}}, + }, + required_inputs=["terms"], + output_schema={ + "results": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": {"type": "string"}, + "model": {"type": "string"}, + "id": {"type": "integer"}, + "date": {"type": "string", "format": "date-time"}, + "record_name": {"type": "string"}, + "author": {"type": "string"}, + }, + }, + }, + }, + }, + ) + def _ai_search_messages(self, terms): + results = {} + for term in terms: + words = term.split() + if not words: + results[term] = [] + continue + word_domains = [ + expression.OR([[(field, "ilike", word)] for field in SEARCH_FIELDS]) + for word in words + ] + domain = expression.AND(word_domains) + messages = self.search(domain, limit=DEFAULT_LIMIT, order="date desc") + results[term] = [ + { + "content": html2plaintext(message.body or "")[:SNIPPET_LEN], + "model": message.model, + "id": message.res_id, + "date": message.date.isoformat() if message.date else "", + "record_name": message.record_name or "", + "author": message.author_id.name or message.email_from or "", + } + for message in messages + if message.model and message.res_id + ] + return {"results": results} + + @aitool( + input_schema={ + "records": { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": {"type": "string"}, + "id": {"type": "integer"}, + }, + "required": ["model", "id"], + }, + }, + }, + required_inputs=["records"], + output_schema={ + "records": { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": {"type": "string"}, + "id": {"type": "integer"}, + "record_name": {"type": "string"}, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": {"type": "string"}, + "date": {"type": "string", "format": "date-time"}, + "author": {"type": "string"}, + }, + }, + }, + }, + }, + }, + }, + ) + def _ai_get_messages_for_records(self, records): + result = [] + for ref in records: + # Cap and keep only the most recent messages, then restore + # chronological order (oldest first) for display. + messages = self.search( + [("model", "=", ref["model"]), ("res_id", "=", ref["id"])], + order="date desc, id desc", + limit=DEFAULT_LIMIT, + )[::-1] + result.append( + { + "model": ref["model"], + "id": ref["id"], + "record_name": messages[-1].record_name if messages else "", + "messages": [ + { + "content": html2plaintext(message.body or ""), + "date": message.date.isoformat() if message.date else "", + "author": message.author_id.name + or message.email_from + or "", + } + for message in messages + ], + } + ) + return {"records": result} diff --git a/ai_tool_mail_message_search/readme/CONTRIBUTORS.md b/ai_tool_mail_message_search/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..04192ea --- /dev/null +++ b/ai_tool_mail_message_search/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Quartile](https://www.quartile.co): + - Toshikimi Shigenobu diff --git a/ai_tool_mail_message_search/readme/DESCRIPTION.md b/ai_tool_mail_message_search/readme/DESCRIPTION.md new file mode 100644 index 0000000..7f2bc72 --- /dev/null +++ b/ai_tool_mail_message_search/readme/DESCRIPTION.md @@ -0,0 +1,12 @@ +This module exposes chatter (`mail.message`) search as `ai_tool` MCP tools, +allowing AI agents to look up conversation history across any record. + +Two tools are provided: + +- **search_messages**: searches chatter messages by one or more keywords + (matching subject, body and record name), returning the matching messages' + content plus the model/id of the record they are attached to, grouped per + keyword. +- **get_messages_for_records**: given a list of `{model, id}` record + references, returns the full chatter message history logged against each + of those records. diff --git a/ai_tool_mail_message_search/static/description/index.html b/ai_tool_mail_message_search/static/description/index.html new file mode 100644 index 0000000..344acf2 --- /dev/null +++ b/ai_tool_mail_message_search/static/description/index.html @@ -0,0 +1,433 @@ + + + + + + +AI Tool - Mail Message Search + + + + + + diff --git a/ai_tool_mail_message_search/tests/__init__.py b/ai_tool_mail_message_search/tests/__init__.py new file mode 100644 index 0000000..22bb2a9 --- /dev/null +++ b/ai_tool_mail_message_search/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mail_message_search diff --git a/ai_tool_mail_message_search/tests/test_mail_message_search.py b/ai_tool_mail_message_search/tests/test_mail_message_search.py new file mode 100644 index 0000000..0d23202 --- /dev/null +++ b/ai_tool_mail_message_search/tests/test_mail_message_search.py @@ -0,0 +1,119 @@ +from odoo.tests.common import TransactionCase + +from ..models.mail_message import SNIPPET_LEN + + +class TestMailMessageSearch(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Search Test Partner"}) + cls.partner.message_post(body="The quick brown fox jumps") + + def test_search_messages_hit(self): + result = self.env["mail.message"]._ai_search_messages(terms=["quick brown"]) + hits = result["results"]["quick brown"] + self.assertTrue(hits) + self.assertEqual(hits[0]["model"], "res.partner") + self.assertEqual(hits[0]["id"], self.partner.id) + self.assertIn("quick brown", hits[0]["content"]) + self.assertEqual(hits[0]["record_name"], self.partner.name) + self.assertEqual(hits[0]["author"], self.env.user.partner_id.name) + self.assertTrue(hits[0]["date"]) + + def test_search_messages_miss(self): + result = self.env["mail.message"]._ai_search_messages( + terms=["no such phrase xyz"] + ) + self.assertEqual(result["results"]["no such phrase xyz"], []) + + def test_search_messages_word_order_independent(self): + # Each word must appear somewhere in the message, not necessarily + # contiguous or in the original order. + result = self.env["mail.message"]._ai_search_messages(terms=["fox quick"]) + hits = result["results"]["fox quick"] + self.assertTrue(hits) + self.assertEqual(hits[0]["id"], self.partner.id) + + def test_search_messages_empty_term(self): + # An empty/whitespace-only term must not fall back to an unfiltered + # (match-everything) search. + result = self.env["mail.message"]._ai_search_messages(terms=[" "]) + self.assertEqual(result["results"][" "], []) + + def test_search_messages_multiple_terms(self): + result = self.env["mail.message"]._ai_search_messages( + terms=["quick brown", "no such phrase xyz"] + ) + self.assertTrue(result["results"]["quick brown"]) + self.assertEqual(result["results"]["no such phrase xyz"], []) + + def test_search_messages_snippet_truncated(self): + partner = self.env["res.partner"].create({"name": "Long Body Partner"}) + partner.message_post(body="uniquetoken " + "x" * 600) + result = self.env["mail.message"]._ai_search_messages(terms=["uniquetoken"]) + hits = result["results"]["uniquetoken"] + self.assertTrue(hits) + self.assertEqual(len(hits[0]["content"]), SNIPPET_LEN) + + def test_search_messages_email_from_fallback(self): + self.env["mail.message"].create( + { + "model": "res.partner", + "res_id": self.partner.id, + "author_id": False, + "email_from": "external@example.com", + "body": "unauthored message", + "message_type": "email", + } + ) + result = self.env["mail.message"]._ai_search_messages(terms=["unauthored"]) + hits = result["results"]["unauthored"] + self.assertTrue(hits) + self.assertEqual(hits[0]["author"], "external@example.com") + + def test_search_messages_excludes_unlinked_messages(self): + # A message with no model/res_id (e.g. a private note) must not be + # returned even if its body matches. + self.env["mail.message"].create( + {"body": "orphankeyword message", "model": False, "res_id": False} + ) + result = self.env["mail.message"]._ai_search_messages(terms=["orphankeyword"]) + self.assertEqual(result["results"]["orphankeyword"], []) + + def test_get_messages_for_records(self): + partner2 = self.env["res.partner"].create({"name": "Second Partner"}) + partner2.message_post(body="another message here") + result = self.env["mail.message"]._ai_get_messages_for_records( + records=[ + {"model": "res.partner", "id": self.partner.id}, + {"model": "res.partner", "id": partner2.id}, + ] + ) + record_result, record_result2 = result["records"] + self.assertEqual(record_result["model"], "res.partner") + self.assertEqual(record_result["id"], self.partner.id) + self.assertEqual(record_result["record_name"], self.partner.name) + self.assertTrue( + any( + "quick brown" in message["content"] + for message in record_result["messages"] + ) + ) + self.assertEqual( + record_result["messages"][0]["author"], self.env.user.partner_id.name + ) + self.assertTrue(record_result["messages"][0]["date"]) + self.assertEqual(record_result2["id"], partner2.id) + self.assertTrue( + any( + "another message here" in message["content"] + for message in record_result2["messages"] + ) + ) + + def test_get_messages_for_records_no_match(self): + result = self.env["mail.message"]._ai_get_messages_for_records( + records=[{"model": "res.partner", "id": self.partner.id + 999999}] + ) + self.assertEqual(result["records"][0]["messages"], []) diff --git a/setup/ai_tool_mail_message_search/odoo/addons/ai_tool_mail_message_search b/setup/ai_tool_mail_message_search/odoo/addons/ai_tool_mail_message_search new file mode 120000 index 0000000..8585ee2 --- /dev/null +++ b/setup/ai_tool_mail_message_search/odoo/addons/ai_tool_mail_message_search @@ -0,0 +1 @@ +../../../../ai_tool_mail_message_search \ No newline at end of file diff --git a/setup/ai_tool_mail_message_search/setup.py b/setup/ai_tool_mail_message_search/setup.py new file mode 100644 index 0000000..28c57bb --- /dev/null +++ b/setup/ai_tool_mail_message_search/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)