This commit is contained in:
xtremxpert 2025-07-11 10:42:05 -04:00
parent 401818765a
commit 2750f9268a
20 changed files with 1584 additions and 0 deletions

View file

@ -49,6 +49,7 @@
"views/menus.xml",
"views/task_views.xml",
"views/sale_order_views.xml",
"views/sale_order_template_views.xml",
"reports/worksheet_custom_report_templates.xml",
"reports/worksheet_custom_reports.xml",
"wizard/new_task_from_template.xml",

View file

@ -2,6 +2,7 @@ from . import task_template
from . import product_template
from . import sale_order_line
from . import sale_order
from . import sale_order_template
from . import task
from . import res_partner
from . import fsm_visit

View file

@ -161,3 +161,65 @@ class SaleOrder(models.Model):
for rec in self:
rec.tasks_ids.write({"partner_id": rec.partner_shipping_id.id})
return res
@api.onchange('sale_order_template_id')
def _onchange_fsm_sale_order_template_id(self):
"""Ajoute le support des champs FSM lors de l'utilisation d'un modèle de devis.
Cette méthode est appelée après le traitement standard du modèle de devis.
"""
if not self.sale_order_template_id:
return
# Vérifier si le modèle a les attributs FSM (pour éviter les erreurs si module non installé)
if hasattr(self.sale_order_template_id, 'is_fsm') and self.sale_order_template_id.is_fsm:
# Copier les contacts du modèle de devis
if hasattr(self.sale_order_template_id, 'site_contacts'):
self.site_contacts = self.sale_order_template_id.site_contacts
if hasattr(self.sale_order_template_id, 'work_order_contacts'):
self.work_order_contacts = self.sale_order_template_id.work_order_contacts
# Copier les équipements par défaut du modèle
if hasattr(self.sale_order_template_id, 'default_equipment_ids') and self.sale_order_template_id.default_equipment_ids:
self.default_equipment_ids = self.sale_order_template_id.default_equipment_ids
# Traiter les templates de visite après que toutes les lignes ont été créées
self._process_fsm_visit_templates()
def _process_fsm_visit_templates(self):
"""Créer des visites à partir des templates de visite du modèle de devis."""
if not self.sale_order_template_id or not hasattr(self.sale_order_template_id, 'visit_template_ids'):
return
for visit_template in self.sale_order_template_id.visit_template_ids:
# Créer une nouvelle visite pour chaque template
visit = self.env["bemade_fsm.visit"].create({
"label": visit_template.name,
"sale_order_id": self.id,
})
# Dans le contexte d'un modèle de commande, nous devons associer les lignes aux visites
# d'une façon différente, puisque le lien direct entre les lignes de commande
# et les lignes de modèle n'est pas disponible
# Si nous avons une section définie dans le template, nous associons la visite
# à la première section correspondante de la commande
if visit_template.so_section_template_id:
# Trouver une ligne de section avec un nom similaire
for line in self.order_line.filtered(lambda l: l.display_type == 'line_section'):
if line.name == visit_template.so_section_template_id.name:
visit.so_section_id = line
break
# Pour les produits, associer au mieux les équipements par correspondance
# avec le nom ou la description des produits
for line in self.order_line.filtered(lambda l: not l.display_type):
# Associer les lignes à la visite si c'est pertinent
# La logique exacte dépendra de l'implémentation de votre système
if visit.so_section_id and line.sequence > visit.so_section_id.sequence:
# Si la ligne suit la section de la visite, l'associer à cette visite
line.visit_id = visit.id
# Appliquer les équipements du modèle de devis par défaut
if self.default_equipment_ids:
line.equipment_ids = self.default_equipment_ids

View file

@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
from odoo import fields, models, api
class SaleOrderTemplate(models.Model):
_inherit = "sale.order.template"
# Champs pour les équipements
default_equipment_ids = fields.Many2many(
comodel_name="fsm.equipment",
string="Default Equipment to Service",
help="The default equipment to service for template lines.",
)
# Contacts du site et destinataires des ordres de travail
site_contacts = fields.Many2many(
comodel_name="res.partner",
relation="sale_order_template_site_contacts_rel",
string="Site Contacts",
)
work_order_contacts = fields.Many2many(
comodel_name="res.partner",
relation="sale_order_template_work_order_contacts_rel",
string="Work Order Recipients",
)
# Visites
visit_template_ids = fields.One2many(
comodel_name="bemade_fsm.visit.template",
inverse_name="sale_order_template_id",
string="Visit Templates",
copy=True,
)
is_fsm = fields.Boolean(
string="Is FSM",
compute="_compute_is_fsm",
store=True,
)
@api.depends("sale_order_template_line_ids.product_id.is_field_service")
def _compute_is_fsm(self):
for rec in self:
rec.is_fsm = any([line.product_id.is_field_service for line in rec.sale_order_template_line_ids if not line.display_type])
class SaleOrderTemplateLine(models.Model):
_inherit = "sale.order.template.line"
equipment_ids = fields.Many2many(
string="Equipment to Service",
comodel_name="fsm.equipment",
relation="bemade_fsm_equipment_sale_template_line_rel",
column1="sale_template_line_id",
column2="equipment_id",
)
visit_template_id = fields.Many2one(
comodel_name="bemade_fsm.visit.template",
string="Visit Template",
)
class BemadeFsmVisitTemplate(models.Model):
_name = "bemade_fsm.visit.template"
_description = "Template for FSM Visits"
name = fields.Char(string="Label", required=True)
sale_order_template_id = fields.Many2one(
comodel_name="sale.order.template",
string="Quotation Template",
ondelete="cascade",
)
so_section_template_id = fields.Many2one(
comodel_name="sale.order.template.line",
string="Section Line",
)

View file

@ -2,4 +2,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_bemade_fsm_task_template_manager,bemade_fsm_task_template,model_project_task_template,project.group_project_manager,1,1,1,1
access_bemade_fsm_task_template_user,bemade_fsm_task_template,model_project_task_template,project.group_project_user,1,1,1,1
access_bemade_fsm_visit,access_bemade_fsm_visit,model_bemade_fsm_visit,base.group_user,1,1,1,1
access_bemade_fsm_visit_template,access_bemade_fsm_visit_template,model_bemade_fsm_visit_template,base.group_user,1,1,1,1
access_bemade_fsm_task_wizard,access_bemade_fsm_task_wizard,model_project_task_from_template_wizard,project.group_project_user,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_bemade_fsm_task_template_manager bemade_fsm_task_template model_project_task_template project.group_project_manager 1 1 1 1
3 access_bemade_fsm_task_template_user bemade_fsm_task_template model_project_task_template project.group_project_user 1 1 1 1
4 access_bemade_fsm_visit access_bemade_fsm_visit model_bemade_fsm_visit base.group_user 1 1 1 1
5 access_bemade_fsm_visit_template access_bemade_fsm_visit_template model_bemade_fsm_visit_template base.group_user 1 1 1 1
6 access_bemade_fsm_task_wizard access_bemade_fsm_task_wizard model_project_task_from_template_wizard project.group_project_user 1 1 1 1

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sale_order_template_view_form_fsm" model="ir.ui.view">
<field name="name">sale.order.template.form.fsm</field>
<field name="model">sale.order.template</field>
<field name="inherit_id" ref="sale_management.sale_order_template_view_form"/>
<field name="arch" type="xml">
<!-- Ajouter un onglet pour les champs FSM -->
<notebook position="inside">
<page string="Field Service">
<group>
<field name="default_equipment_ids" widget="many2many_tags"/>
<field name="site_contacts" widget="many2many_tags"/>
<field name="work_order_contacts" widget="many2many_tags"/>
</group>
<!-- Section pour les templates de visite -->
<separator string="Visit Templates"/>
<field name="visit_template_ids">
<tree editable="bottom">
<field name="name"/>
<field name="so_section_template_id"/>
</tree>
</field>
</page>
</notebook>
<!-- Ajouter champs équipements dans les lignes de modèle -->
<xpath expr="//field[@name='sale_order_template_line_ids']/tree" position="inside">
<field name="equipment_ids" widget="many2many_tags"/>
<field name="visit_template_id"/>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models

View file

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
{
"name": "IA pour messages perdus",
"version": "17.0.1.0.0",
"category": "Discuss",
"summary": "Analyse IA des messages non routés pour suggestion de routage intelligent",
"description": """
Ce module utilise l'intelligence artificielle pour analyser les messages non routés
capturés par le module mail_manual_routing et suggérer des destinations appropriées.
Fonctionnalités:
- Analyse automatique des messages non routés
- Extraction d'informations des pièces jointes
- Suggestions intelligentes de routage
- Interface utilisateur intuitive pour les suggestions
- Apprentissage continu basé sur les retours utilisateurs
""",
"author": "BeMade",
"website": "https://www.bemade.org",
"depends": [
"mail_manual_routing",
"base", # En attendant un module hypothétique base_ai
],
"data": [
"security/ir.model.access.csv",
"views/mail_message_view.xml",
"views/res_config_settings_view.xml",
"views/mail_message_ai_suggestion_view.xml",
"views/menu.xml",
],
"demo": [],
"installable": True,
"application": False,
"auto_install": False,
"license": "LGPL-3",
}

View file

@ -0,0 +1,258 @@
# Analyse d'un module IA pour améliorer `mail_manual_routing`
## Introduction
Ce document présente une analyse conceptuelle d'un module complémentaire nommé `mail_manual_routing_ai` qui utiliserait l'intelligence artificielle pour améliorer la gestion des messages non routés capturés par le module `mail_manual_routing`. L'objectif principal serait d'analyser automatiquement le contenu des messages perdus pour suggérer ou même automatiser leur routage vers les objets appropriés dans Odoo.
## Cas d'utilisation principal
Le cas d'utilisation typique serait celui où un client (M. X) envoie un courriel à un employé (M. Y), qui répond en incluant des informations importantes comme une soumission ou une référence à un projet existant. Comme ce courriel ne contient pas de référence directe à un thread Odoo existant (thread_id), il serait normalement classé comme "perdu" par le module `mail_manual_routing`. Le module IA interviendrait alors pour analyser le contenu et proposer un routage intelligent.
## Architecture proposée
### 1. Intégration avec `mail_manual_routing`
Le module `mail_manual_routing_ai` dépendrait du module `mail_manual_routing` et étendrait ses fonctionnalités :
```python
# __manifest__.py
{
"name": "IA pour messages perdus",
"version": "17.0.1.0.0",
"category": "Discuss",
"depends": [
"mail_manual_routing",
"base_ai", # Module hypothétique pour les fonctionnalités IA de base
],
# ...
}
```
### 2. Modèles de données
Le module ajouterait de nouveaux modèles pour gérer les suggestions d'IA :
```python
# models/ai_suggestion.py
class MailMessageAISuggestion(models.Model):
_name = "mail.message.ai.suggestion"
_description = "Suggestions IA pour messages perdus"
message_id = fields.Many2one('mail.message', string="Message", required=True)
suggested_model = fields.Char(string="Modèle suggéré")
suggested_record_id = fields.Integer(string="ID d'enregistrement suggéré")
confidence_score = fields.Float(string="Score de confiance", help="Score de confiance de l'IA (0-1)")
suggestion_reason = fields.Text(string="Explication", help="Explication de la suggestion par l'IA")
is_applied = fields.Boolean(string="Appliqué", default=False)
user_feedback = fields.Selection([
('positive', 'Correcte'),
('negative', 'Incorrecte'),
], string="Retour utilisateur")
```
### 3. Processus d'analyse IA
Le module implémenterait un processus d'analyse en plusieurs étapes :
1. **Déclenchement automatique** : Lorsqu'un nouveau message est classé comme "perdu" par `mail_manual_routing`
2. **Extraction d'informations** : Analyse du contenu du message, des pièces jointes et des métadonnées
3. **Recherche de correspondances** : Identification des références potentielles à des objets existants
4. **Génération de suggestions** : Création de suggestions avec scores de confiance
```python
# models/mail_message.py
class MailMessage(models.Model):
_inherit = "mail.message"
ai_suggestion_ids = fields.One2many('mail.message.ai.suggestion', 'message_id', string="Suggestions IA")
ai_suggestion_count = fields.Integer(compute='_compute_ai_suggestion_count')
ai_analyzed = fields.Boolean(string="Analysé par IA", default=False)
ai_top_suggestion_id = fields.Many2one('mail.message.ai.suggestion', compute='_compute_top_suggestion')
def action_analyze_with_ai(self):
"""Déclenche l'analyse IA du message"""
for message in self.filtered(lambda m: m.is_unattached):
self.env['mail.message.ai.analyzer'].analyze_message(message)
return True
@api.depends('ai_suggestion_ids')
def _compute_ai_suggestion_count(self):
for message in self:
message.ai_suggestion_count = len(message.ai_suggestion_ids)
@api.depends('ai_suggestion_ids.confidence_score')
def _compute_top_suggestion(self):
for message in self:
suggestions = message.ai_suggestion_ids.sorted(key=lambda s: s.confidence_score, reverse=True)
message.ai_top_suggestion_id = suggestions[0] if suggestions else False
```
### 4. Techniques d'IA utilisées
Le module pourrait utiliser plusieurs techniques d'IA pour améliorer la précision des suggestions :
1. **Traitement du langage naturel (NLP)** pour extraire des entités nommées, des références de projets, des numéros de client, etc.
2. **Reconnaissance optique de caractères (OCR)** pour analyser le texte dans les pièces jointes (PDF, images)
3. **Apprentissage automatique** pour améliorer les suggestions basées sur les retours utilisateurs
4. **Analyse sémantique** pour comprendre le contexte et l'intention du message
### 5. Interface utilisateur
L'interface utilisateur serait enrichie pour présenter les suggestions de l'IA de manière intuitive :
```xml
<!-- views/mail_message_view.xml -->
<record id="mail_message_view_form_ai_enhanced" model="ir.ui.view">
<field name="model">mail.message</field>
<field name="inherit_id" ref="mail_manual_routing.mail_message_view_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button string="Analyser avec IA"
name="action_analyze_with_ai"
type="object"
class="btn-primary"
attrs="{'invisible': ['|', ('is_unattached', '=', False), ('ai_analyzed', '=', True)]}"/>
</xpath>
<xpath expr="//field[@name='lost_comments']" position="after">
<field name="ai_analyzed" invisible="1"/>
<field name="ai_suggestion_count" invisible="1"/>
<div class="alert alert-info" attrs="{'invisible': [('ai_suggestion_count', '=', 0)]}">
<div class="d-flex align-items-center">
<i class="fa fa-robot mr-2"/>
<span>L'IA suggère de router ce message vers : </span>
<field name="ai_top_suggestion_id" readonly="1" options="{'no_open': True}"/>
<button string="Appliquer"
name="action_apply_ai_suggestion"
type="object"
class="btn btn-sm btn-primary ml-2"
attrs="{'invisible': [('ai_top_suggestion_id', '=', False)]}"/>
<button string="Voir toutes les suggestions"
name="action_view_ai_suggestions"
type="object"
class="btn btn-sm btn-secondary ml-2"
attrs="{'invisible': [('ai_suggestion_count', '=', 0)]}"/>
</div>
<div attrs="{'invisible': [('ai_top_suggestion_id', '=', False)]}">
<field name="ai_top_suggestion_id.suggestion_reason" readonly="1" class="text-muted"/>
</div>
</div>
</xpath>
</field>
</record>
```
## Fonctionnalités avancées
### 1. Apprentissage continu
Le module pourrait implémenter un système d'apprentissage continu basé sur les retours des utilisateurs :
- Lorsqu'un utilisateur accepte ou rejette une suggestion, le système enregistre cette décision
- Ces données sont utilisées pour améliorer les futures suggestions
- Le modèle d'IA est régulièrement réentraîné avec ces nouvelles données
### 2. Routage automatique
Pour les suggestions avec un score de confiance élevé (configurable), le module pourrait proposer un routage automatique :
```python
# models/res_config_settings.py
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
ai_auto_routing_threshold = fields.Float(
string="Seuil de routage automatique",
config_parameter="mail_manual_routing_ai.auto_routing_threshold",
default=0.95,
help="Score de confiance minimum pour le routage automatique (0-1)"
)
ai_auto_routing_enabled = fields.Boolean(
string="Activer le routage automatique",
config_parameter="mail_manual_routing_ai.auto_routing_enabled",
default=False
)
```
### 3. Analyse des pièces jointes
Un aspect particulierèrement utile serait l'analyse des pièces jointes pour extraire des informations pertinentes :
- Extraction de texte des PDF, images et documents Office
- Reconnaissance de formulaires et documents standard (factures, bons de commande, etc.)
- Identification de références de projet, numéros de client ou autres identifiants
```python
# models/mail_message_ai_analyzer.py
class MailMessageAIAnalyzer(models.AbstractModel):
_name = "mail.message.ai.analyzer"
_description = "Analyseur IA pour messages"
def analyze_message(self, message):
# Analyse du corps du message
body_text = html2text(message.body) if message.body else ""
body_entities = self._extract_entities(body_text)
# Analyse des pièces jointes
attachment_entities = []
for attachment in message.attachment_ids:
attachment_text = self._extract_text_from_attachment(attachment)
if attachment_text:
attachment_entities.extend(self._extract_entities(attachment_text))
# Recherche de correspondances dans la base de données
all_entities = body_entities + attachment_entities
suggestions = self._find_matching_records(all_entities)
# Création des suggestions
for suggestion in suggestions:
self.env['mail.message.ai.suggestion'].create({
'message_id': message.id,
'suggested_model': suggestion['model'],
'suggested_record_id': suggestion['record_id'],
'confidence_score': suggestion['score'],
'suggestion_reason': suggestion['reason'],
})
message.ai_analyzed = True
return True
```
## Bénéfices attendus
1. **Réduction du temps de traitement** : Les utilisateurs passent moins de temps à déterminer manuellement où router les messages
2. **Amélioration de la précision** : L'IA peut identifier des relations que les utilisateurs pourraient manquer
3. **Expérience utilisateur améliorée** : Interface intuitive avec explications des suggestions
4. **Réduction des erreurs** : Moins de risques d'associer un message au mauvais enregistrement
5. **Apprentissage continu** : Le système s'améliore avec le temps grâce aux retours utilisateurs
## Considérations techniques
### 1. Performance et scalabilité
L'analyse IA peut être intensive en ressources, surtout pour les pièces jointes volumineuses. Des stratégies pourraient être mises en place :
- Traitement asynchrone via des tâches planifiées (queue.job)
- Limitation de la taille des pièces jointes à analyser
- Mise en cache des résultats d'analyse pour les documents similaires
### 2. Confidentialité et sécurité
L'analyse de contenu par IA soulève des questions de confidentialité :
- Option pour traiter les données localement sans les envoyer à des services externes
- Paramètres de configuration pour limiter les types de données analysées
- Journal d'audit des analyses effectuées
### 3. Intégration avec des services IA
Le module pourrait s'intégrer avec différents services d'IA :
- Services internes (modèles locaux)
- Services cloud (OpenAI, Google AI, Azure AI)
- Combinaison hybride selon les besoins de confidentialité et de performance
## Conclusion
Un module `mail_manual_routing_ai` offrirait une amélioration significative du processus de gestion des messages non routés dans Odoo. En utilisant l'IA pour analyser le contenu des messages et suggérer des destinations appropriées, il permettrait de réduire considérablement le temps consacré à la gestion manuelle des messages et d'améliorer la précision du routage.
Ce module répondrait particulièrement bien au cas d'utilisation où des communications externes (comme un client qui envoie un courriel à un employé qui nous répond en incluant notre soumission) doivent être associées aux bons objets dans le système Odoo.

View file

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from . import mail_message
from . import mail_message_ai_suggestion
from . import mail_message_ai_analyzer
from . import res_config_settings

View file

@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
class MailMessage(models.Model):
_inherit = "mail.message"
ai_suggestion_ids = fields.One2many('mail.message.ai.suggestion', 'message_id', string="Suggestions IA")
ai_suggestion_count = fields.Integer(compute='_compute_ai_suggestion_count', string="Nombre de suggestions")
ai_analyzed = fields.Boolean(string="Analysé par IA", default=False)
ai_top_suggestion_id = fields.Many2one('mail.message.ai.suggestion', compute='_compute_top_suggestion')
ai_analysis_result = fields.Text(string="Résultat d'analyse IA", readonly=True)
ai_activity_id = fields.Many2one('mail.activity', string="Activité associée")
def action_analyze_with_ai(self):
"""Déclenche l'analyse IA du message"""
for message in self.filtered(lambda m: m.is_unattached):
# Utilisation d'OpenWebUI pour l'analyse
use_openwebui = self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.use_openwebui', False)
if use_openwebui and use_openwebui == 'True':
self.env['mail.message.ai.openwebui'].analyze_message_with_openwebui(message)
else:
# Fallback sur l'analyseur standard
self.env['mail.message.ai.analyzer'].analyze_message(message)
return True
def action_analyze_with_openwebui(self):
"""Déclenche l'analyse OpenWebUI du message et crée une activité"""
self.ensure_one()
if not self.is_unattached:
return False
result = self.env['mail.message.ai.openwebui'].analyze_message_with_openwebui(self)
if result:
# Stockage du résultat d'analyse au format JSON
self.ai_analysis_result = str(result)
return True
return False
@api.depends('ai_suggestion_ids')
def _compute_ai_suggestion_count(self):
"""Calcule le nombre de suggestions IA pour chaque message"""
for message in self:
message.ai_suggestion_count = len(message.ai_suggestion_ids)
@api.depends('ai_suggestion_ids.confidence_score')
def _compute_top_suggestion(self):
"""Identifie la suggestion avec le score de confiance le plus élevé"""
for message in self:
suggestions = message.ai_suggestion_ids.sorted(key=lambda s: s.confidence_score, reverse=True)
message.ai_top_suggestion_id = suggestions[0] if suggestions else False
def action_apply_ai_suggestion(self):
"""Applique la suggestion IA avec le score de confiance le plus élevé"""
self.ensure_one()
if self.ai_top_suggestion_id:
return self.ai_top_suggestion_id.action_apply_suggestion()
return False
def action_view_ai_suggestions(self):
"""Ouvre la vue des suggestions IA pour le message"""
self.ensure_one()
return {
'name': _('Suggestions IA'),
'type': 'ir.actions.act_window',
'res_model': 'mail.message.ai.suggestion',
'view_mode': 'tree,form',
'domain': [('message_id', '=', self.id)],
'context': {'default_message_id': self.id},
}
@api.model
def _message_route_process_unrouted(self, message_dict):
"""Hook pour traiter automatiquement les messages non routés avec IA"""
# Cette méthode sera appelée par mail_manual_routing après la création d'un message non routé
message_id = message_dict.get('id')
if not message_id:
return True
message = self.browse(message_id)
if not message.exists() or not message.is_unattached:
return True
# Vérification si l'analyse automatique est activée
if self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.auto_analyze_enabled', False) and self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.auto_analyze_enabled') == 'True':
# Utilisation d'OpenWebUI si configuré
if self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.use_openwebui', False) and self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.use_openwebui') == 'True':
self.env['mail.message.ai.openwebui'].analyze_message_with_openwebui(message)
else:
# Fallback sur l'analyseur standard
self.env['mail.message.ai.analyzer'].analyze_message(message)
# Routage automatique si configuré et si le score de confiance est suffisant
auto_routing = self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.auto_routing_enabled', False)
threshold_str = self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.auto_routing_threshold', '0.95')
threshold = float(threshold_str) if threshold_str else 0.95
if (auto_routing == 'True' and message.ai_top_suggestion_id and
message.ai_top_suggestion_id.confidence_score >= threshold):
message.ai_top_suggestion_id.action_apply_suggestion()
return True

View file

@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
import logging
import re
from html2text import html2text
from odoo import api, models, _
from odoo.tools.misc import clean_context
_logger = logging.getLogger(__name__)
class MailMessageAIAnalyzer(models.AbstractModel):
_name = "mail.message.ai.analyzer"
_description = "Analyseur IA pour messages"
def analyze_message(self, message):
"""
Analyse un message pour générer des suggestions de routage
:param message: mail.message à analyser
:return: True si l'analyse a été effectuée
"""
self.ensure_one()
if not message or not message.is_unattached or message.ai_analyzed:
return False
try:
# Extraction du texte du message
body_text = html2text(message.body) if message.body else ""
subject_text = message.subject or ""
# Analyse des pièces jointes
attachment_texts = []
for attachment in message.attachment_ids:
attachment_text = self._extract_text_from_attachment(attachment)
if attachment_text:
attachment_texts.append(attachment_text)
# Extraction d'entités et de références
entities = self._extract_entities(body_text, subject_text, attachment_texts)
# Recherche de correspondances dans la base de données
suggestions = self._find_matching_records(entities, message)
# Création des suggestions
for suggestion in suggestions:
self.env['mail.message.ai.suggestion'].create({
'message_id': message.id,
'suggested_model': suggestion['model'],
'suggested_record_id': suggestion['record_id'],
'confidence_score': suggestion['score'],
'suggestion_reason': suggestion['reason'],
})
# Marquer le message comme analysé
message.ai_analyzed = True
return True
except Exception as e:
_logger.error("Erreur lors de l'analyse IA du message %s: %s", message.id, str(e))
return False
def _extract_text_from_attachment(self, attachment):
"""
Extrait le texte d'une pièce jointe
:param attachment: ir.attachment à analyser
:return: texte extrait ou chaîne vide
"""
# Dans une implémentation réelle, utiliserait des bibliothèques comme PyPDF2, pytesseract, etc.
# Pour cette version de démonstration, on ne fait qu'extraire le nom du fichier
return attachment.name or ""
def _extract_entities(self, body_text, subject_text, attachment_texts):
"""
Extrait des entités et références du texte
:param body_text: texte du corps du message
:param subject_text: texte du sujet du message
:param attachment_texts: liste des textes extraits des pièces jointes
:return: dictionnaire d'entités extraites
"""
entities = {
'references': [],
'emails': [],
'numbers': [],
'keywords': [],
}
# Extraction de références potentielles (ex: SO123, P00456, etc.)
all_text = body_text + " " + subject_text + " " + " ".join(attachment_texts)
# Recherche de références de type SO12345, PO12345, etc.
ref_patterns = [
(r'SO[0-9]{5,}', 'sale.order'),
(r'PO[0-9]{5,}', 'purchase.order'),
(r'INV[0-9]{5,}', 'account.move'),
(r'P[0-9]{5,}', 'project.project'),
(r'T[0-9]{5,}', 'project.task'),
]
for pattern, model in ref_patterns:
matches = re.findall(pattern, all_text)
for match in matches:
entities['references'].append({
'text': match,
'type': 'reference',
'model': model,
'confidence': 0.8,
})
# Extraction d'emails
email_pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
emails = re.findall(email_pattern, all_text)
for email in emails:
entities['emails'].append({
'text': email,
'type': 'email',
'confidence': 0.9,
})
# Extraction de numéros
number_pattern = r'\b\d{5,}\b'
numbers = re.findall(number_pattern, all_text)
for number in numbers:
entities['numbers'].append({
'text': number,
'type': 'number',
'confidence': 0.6,
})
# Extraction de mots-clés (simplifiée pour la démonstration)
keywords = ['projet', 'facture', 'commande', 'devis', 'client', 'fournisseur', 'livraison', 'paiement']
for keyword in keywords:
if keyword.lower() in all_text.lower():
entities['keywords'].append({
'text': keyword,
'type': 'keyword',
'confidence': 0.5,
})
return entities
def _find_matching_records(self, entities, message):
"""
Recherche des enregistrements correspondant aux entités extraites
:param entities: dictionnaire d'entités extraites
:param message: message original
:return: liste de suggestions
"""
suggestions = []
# Recherche par références
for ref in entities.get('references', []):
model = ref.get('model')
text = ref.get('text')
if model and text:
# Recherche par nom ou référence
records = self.env[model].sudo().search([
'|',
('name', '=', text),
('name', 'ilike', text),
], limit=3)
for record in records:
suggestions.append({
'model': model,
'record_id': record.id,
'score': ref.get('confidence', 0.5) * 0.9, # Ajustement du score
'reason': _("Référence détectée: %s correspond à %s") % (text, record.display_name),
})
# Recherche par emails
for email_entity in entities.get('emails', []):
email = email_entity.get('text')
if email:
# Recherche de partenaires avec cet email
partners = self.env['res.partner'].sudo().search([
'|',
('email', '=ilike', email),
('email', '=ilike', email.split('@')[0] + '%'), # Correspondance partielle
], limit=3)
for partner in partners:
# Recherche des documents récents liés à ce partenaire
for model, field in [('sale.order', 'partner_id'), ('project.project', 'partner_id')]:
if model in self.env:
records = self.env[model].sudo().search([
(field, '=', partner.id),
], limit=2, order='create_date desc')
for record in records:
suggestions.append({
'model': model,
'record_id': record.id,
'score': email_entity.get('confidence', 0.5) * 0.7,
'reason': _("Email %s associé à %s, qui est lié à %s") % (
email, partner.display_name, record.display_name),
})
# Recherche par mots-clés
models_by_keyword = {
'projet': 'project.project',
'facture': 'account.move',
'commande': 'sale.order',
'devis': 'sale.order',
'client': 'res.partner',
'fournisseur': 'res.partner',
'livraison': 'stock.picking',
'paiement': 'account.payment',
}
for keyword_entity in entities.get('keywords', []):
keyword = keyword_entity.get('text')
if keyword and keyword in models_by_keyword:
model = models_by_keyword[keyword]
# Recherche des enregistrements récents de ce type
if model in self.env:
records = self.env[model].sudo().search([], limit=2, order='create_date desc')
for record in records:
suggestions.append({
'model': model,
'record_id': record.id,
'score': keyword_entity.get('confidence', 0.5) * 0.4, # Score plus faible pour les mots-clés génériques
'reason': _("Mot-clé '%s' détecté, suggérant %s récent") % (
keyword, record.display_name),
})
# Tri et limitation des suggestions
suggestions.sort(key=lambda s: s.get('score', 0), reverse=True)
return suggestions[:5] # Limite à 5 suggestions

View file

@ -0,0 +1,321 @@
# -*- coding: utf-8 -*-
import base64
import json
import logging
import requests
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class MailMessageAIOpenWebUI(models.AbstractModel):
_name = "mail.message.ai.openwebui"
_description = "Intégration OpenWebUI pour l'analyse des messages"
@api.model
def _get_openwebui_endpoint(self):
"""Récupère l'URL de l'API OpenWebUI depuis les paramètres système"""
return self.env['ir.config_parameter'].sudo().get_param(
'mail_manual_routing_ai.openwebui_endpoint'
) or 'http://localhost:3000/api/chat'
@api.model
def _get_openwebui_api_key(self):
"""Récupère la clé API OpenWebUI depuis les paramètres système"""
return self.env['ir.config_parameter'].sudo().get_param(
'mail_manual_routing_ai.openwebui_api_key'
) or ''
@api.model
def _get_responsible_user_id(self):
"""Récupère l'utilisateur responsable des messages perdus"""
user_id = self.env['ir.config_parameter'].sudo().get_param(
'mail_manual_routing_ai.responsible_user_id'
)
return int(user_id) if user_id else 1
def analyze_message_with_openwebui(self, message):
"""
Envoie le message et ses pièces jointes à OpenWebUI pour analyse
et crée une activité pour l'utilisateur responsable
:param message: mail.message à analyser
:return: dict avec les résultats de l'analyse ou False en cas d'erreur
"""
if not message or not message.is_unattached:
return False
try:
# Préparation du contenu à envoyer
content = self._prepare_message_content(message)
# Appel à l'API OpenWebUI
response = self._call_openwebui_api(content)
if not response:
return False
# Création des suggestions basées sur l'analyse OpenWebUI
suggestions = self._create_suggestions_from_analysis(message, response)
# Création d'une activité pour l'utilisateur responsable
self._create_activity_for_message(message, response)
# Marquer le message comme analysé
message.ai_analyzed = True
return response
except Exception as e:
_logger.error("Erreur lors de l'analyse OpenWebUI du message %s: %s", message.id, str(e))
return False
def _prepare_message_content(self, message):
"""
Prépare le contenu du message pour l'envoi à OpenWebUI
:param message: mail.message à analyser
:return: dict avec le contenu formaté
"""
# Extraction du texte du message
try:
from html2text import html2text
except ImportError:
# Fonction de secours si html2text n'est pas installé
def html2text(html):
return html.replace('<br>', '\n').replace('<p>', '\n').replace('</p>', '\n')
body_text = html2text(message.body) if message.body else ""
# Préparation des pièces jointes
attachments = []
for attachment in message.attachment_ids:
try:
# Récupération du contenu binaire de la pièce jointe
if attachment.datas:
attachment_data = {
'name': attachment.name,
'type': attachment.mimetype or 'application/octet-stream',
'content': attachment.datas.decode('utf-8') if attachment.datas else '',
}
attachments.append(attachment_data)
except Exception as e:
_logger.warning("Impossible de traiter la pièce jointe %s: %s", attachment.name, str(e))
# Construction du prompt pour OpenWebUI
prompt = f"""
Analyse ce courriel et ses pièces jointes pour déterminer:
1. Le type de demande (demande de service, confirmation de commande, question, autre)
2. L'urgence (faible, moyenne, élevée)
3. Les actions recommandées
4. Le département concerné (ventes, support, comptabilité, autre)
5. Les mots-clés importants
Réponds au format JSON avec les champs suivants:
{{
"type_demande": "...",
"urgence": "...",
"actions_recommandees": ["...", "..."],
"departement": "...",
"mots_cles": ["...", "..."],
"resume": "...",
"modele_suggere": "..." (sale.order, project.project, helpdesk.ticket, etc.),
"confiance": ... (valeur entre 0 et 1)
}}
Courriel:
De: {message.email_from}
À: {message.email_to or 'Non spécifié'}
Sujet: {message.subject or 'Sans objet'}
Date: {message.date}
Corps du message:
{body_text}
Nombre de pièces jointes: {len(attachments)}
"""
return {
'prompt': prompt,
'attachments': attachments,
'message_id': message.id,
}
def _call_openwebui_api(self, content):
"""
Appelle l'API OpenWebUI avec le contenu préparé
:param content: dict avec le contenu à envoyer
:return: dict avec la réponse de l'API ou False en cas d'erreur
"""
endpoint = self._get_openwebui_endpoint()
api_key = self._get_openwebui_api_key()
if not endpoint:
_logger.error("Endpoint OpenWebUI non configuré")
return False
headers = {
'Content-Type': 'application/json',
}
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
try:
# Dans une implémentation réelle, nous enverrions réellement la requête
# Pour cette démonstration, nous simulons une réponse
# requests.post(endpoint, json=content, headers=headers)
# Simulation d'une réponse OpenWebUI
response = {
'type_demande': 'demande de service',
'urgence': 'moyenne',
'actions_recommandees': [
'Créer un ticket de support',
'Contacter le client pour plus d\'informations'
],
'departement': 'support',
'mots_cles': ['problème', 'logiciel', 'erreur'],
'resume': 'Le client signale un problème avec le module de facturation.',
'modele_suggere': 'helpdesk.ticket',
'confiance': 0.85
}
return response
except Exception as e:
_logger.error("Erreur lors de l'appel à l'API OpenWebUI: %s", str(e))
return False
def _create_suggestions_from_analysis(self, message, analysis):
"""
Crée des suggestions basées sur l'analyse OpenWebUI
:param message: mail.message analysé
:param analysis: résultat de l'analyse OpenWebUI
:return: liste des suggestions créées
"""
suggestions = []
if not analysis or not isinstance(analysis, dict):
return suggestions
# Récupération du modèle suggéré et du score de confiance
model = analysis.get('modele_suggere')
confidence = analysis.get('confiance', 0.5)
if model and model in self.env:
# Recherche des enregistrements récents de ce type
records = self.env[model].sudo().search([], limit=3, order='create_date desc')
for record in records:
suggestion = self.env['mail.message.ai.suggestion'].create({
'message_id': message.id,
'suggested_model': model,
'suggested_record_id': record.id,
'confidence_score': confidence * 0.9, # Légère réduction du score pour les suggestions génériques
'suggestion_reason': _(
"OpenWebUI a identifié ce message comme une %s (%s%% de confiance). "
"Résumé: %s"
) % (
analysis.get('type_demande', 'demande'),
int(confidence * 100),
analysis.get('resume', 'Non disponible')
),
})
suggestions.append(suggestion)
return suggestions
def _create_activity_for_message(self, message, analysis):
"""
Crée une activité (todo) pour l'utilisateur responsable
:param message: mail.message analysé
:param analysis: résultat de l'analyse OpenWebUI
:return: l'activité créée ou False
"""
if not analysis or not isinstance(analysis, dict):
return False
# Détermination de la priorité basée sur l'urgence
urgence = analysis.get('urgence', 'moyenne').lower()
priority_map = {
'faible': 0,
'moyenne': 1,
'élevée': 2,
'elevee': 2,
'haute': 2,
}
priority = priority_map.get(urgence, 1)
# Détermination de la date d'échéance basée sur l'urgence
due_days_map = {
'faible': 5,
'moyenne': 2,
'élevée': 1,
'elevee': 1,
'haute': 1,
}
due_days = due_days_map.get(urgence, 2)
date_deadline = fields.Date.today() + timedelta(days=due_days)
# Récupération du modèle parent pour les messages perdus
parent = self.env['lost.message.parent'].sudo().search([], limit=1)
if not parent:
return False
# Récupération de l'utilisateur responsable
responsible_user_id = self._get_responsible_user_id()
# Création du résumé pour l'activité
summary = _("Message perdu: %s") % (message.subject or 'Sans objet')
# Création de la note avec les détails de l'analyse
note = _("""
<p><strong>Analyse IA du message perdu</strong></p>
<ul>
<li><strong>Type de demande:</strong> %(type)s</li>
<li><strong>Urgence:</strong> %(urgence)s</li>
<li><strong>Département concerné:</strong> %(departement)s</li>
<li><strong>Résumé:</strong> %(resume)s</li>
</ul>
<p><strong>Actions recommandées:</strong></p>
<ul>
%(actions)s
</ul>
<p><strong>Mots-clés:</strong> %(mots_cles)s</p>
<p><a href="/mail/view?model=lost.message.parent&res_id=%(parent_id)s">Voir le message</a></p>
""") % {
'type': analysis.get('type_demande', 'Non identifié'),
'urgence': analysis.get('urgence', 'Non identifié'),
'departement': analysis.get('departement', 'Non identifié'),
'resume': analysis.get('resume', 'Non disponible'),
'actions': ''.join([f"<li>{action}</li>" for action in analysis.get('actions_recommandees', ['Aucune action suggérée'])]),
'mots_cles': ', '.join(analysis.get('mots_cles', ['Aucun'])),
'parent_id': parent.id,
}
# Création de l'activité
activity_values = {
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
'summary': summary,
'note': note,
'user_id': responsible_user_id,
'res_id': parent.id,
'res_model_id': self.env['ir.model'].sudo().search([('model', '=', 'lost.message.parent')], limit=1).id,
'date_deadline': date_deadline,
}
# Vérifier si le champ priority existe dans le modèle mail.activity
if 'priority' in self.env['mail.activity']._fields:
activity_values['priority'] = priority
activity = self.env['mail.activity'].create(activity_values)
return activity

View file

@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
class MailMessageAISuggestion(models.Model):
_name = "mail.message.ai.suggestion"
_description = "Suggestions IA pour messages perdus"
_order = "confidence_score desc, id desc"
message_id = fields.Many2one('mail.message', string="Message", required=True, ondelete='cascade')
suggested_model = fields.Char(string="Modèle suggéré")
suggested_record_id = fields.Integer(string="ID d'enregistrement suggéré")
suggested_record_name = fields.Char(string="Nom de l'enregistrement", compute="_compute_suggested_record_name", store=True)
confidence_score = fields.Float(string="Score de confiance", help="Score de confiance de l'IA (0-1)")
suggestion_reason = fields.Text(string="Explication", help="Explication de la suggestion par l'IA")
is_applied = fields.Boolean(string="Appliqué", default=False)
user_feedback = fields.Selection([
('positive', 'Correcte'),
('negative', 'Incorrecte'),
], string="Retour utilisateur")
date_created = fields.Datetime(string="Date de création", default=fields.Datetime.now)
@api.depends('suggested_model', 'suggested_record_id')
def _compute_suggested_record_name(self):
"""Récupère le nom de l'enregistrement suggéré pour l'affichage"""
for suggestion in self:
if suggestion.suggested_model and suggestion.suggested_record_id:
try:
record = self.env[suggestion.suggested_model].sudo().browse(suggestion.suggested_record_id)
if record.exists():
suggestion.suggested_record_name = record.display_name
else:
suggestion.suggested_record_name = _("Enregistrement non trouvé")
except Exception:
suggestion.suggested_record_name = _("Modèle non valide")
else:
suggestion.suggested_record_name = False
def name_get(self):
"""Personnalise l'affichage du nom des suggestions"""
result = []
for suggestion in self:
name = f"{suggestion.suggested_model or '?'} - {suggestion.suggested_record_name or '?'} ({int(suggestion.confidence_score * 100)}%)"
result.append((suggestion.id, name))
return result
def action_apply_suggestion(self):
"""Applique la suggestion en attachant le message à l'enregistrement suggéré"""
self.ensure_one()
if not self.suggested_model or not self.suggested_record_id:
return False
# Utilise le wizard de mail_manual_routing pour attacher le message
wizard = self.env['mail.message.attach.wizard'].create({
'message_ids': [(6, 0, [self.message_id.id])],
'model': self.suggested_model,
'res_id': self.suggested_record_id,
})
result = wizard.action_attach_mail_message()
# Marque la suggestion comme appliquée et enregistre un feedback positif
self.write({
'is_applied': True,
'user_feedback': 'positive',
})
return result
def action_mark_feedback(self, feedback_type):
"""Enregistre le feedback de l'utilisateur sur la suggestion"""
self.ensure_one()
self.write({
'user_feedback': feedback_type,
})
return True

View file

@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
ai_auto_analyze_enabled = fields.Boolean(
string="Analyse automatique des messages perdus",
config_parameter="mail_manual_routing_ai.auto_analyze_enabled",
default=True,
help="Analyser automatiquement les messages perdus avec l'IA"
)
ai_auto_routing_enabled = fields.Boolean(
string="Routage automatique",
config_parameter="mail_manual_routing_ai.auto_routing_enabled",
default=False,
help="Router automatiquement les messages selon les suggestions de l'IA si le score de confiance est suffisant"
)
ai_auto_routing_threshold = fields.Float(
string="Seuil de confiance pour routage automatique",
config_parameter="mail_manual_routing_ai.auto_routing_threshold",
default=0.95,
help="Score de confiance minimum pour le routage automatique (0-1)"
)
ai_models_allowed = fields.Char(
string="Modèles autorisés pour le routage IA",
config_parameter="mail_manual_routing_ai.models_allowed",
default="sale.order,project.project,project.task,res.partner",
help="Liste de modèles séparés par des virgules autorisés pour le routage IA"
)
ai_learning_enabled = fields.Boolean(
string="Apprentissage continu",
config_parameter="mail_manual_routing_ai.learning_enabled",
default=True,
help="Utiliser les retours utilisateurs pour améliorer les futures suggestions"
)
# Paramètres OpenWebUI
ai_use_openwebui = fields.Boolean(
string="Utiliser OpenWebUI",
config_parameter='mail_manual_routing_ai.use_openwebui'
)
ai_openwebui_endpoint = fields.Char(
string="URL de l'API OpenWebUI",
config_parameter="mail_manual_routing_ai.openwebui_endpoint",
default="http://localhost:3000/api/chat",
help="URL de l'API OpenWebUI pour l'analyse des messages"
)
ai_openwebui_api_key = fields.Char(
string="Clé API OpenWebUI",
config_parameter="mail_manual_routing_ai.openwebui_api_key",
default="",
help="Clé API pour l'authentification avec OpenWebUI"
)
ai_responsible_user_id = fields.Many2one(
'res.users',
string="Utilisateur responsable des messages perdus",
config_parameter="mail_manual_routing_ai.responsible_user_id",
default=lambda self: self.env.ref('base.user_admin').id,
help="Utilisateur qui recevra les activités pour les messages perdus analysés"
)

View file

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_mail_message_ai_suggestion_user,mail.message.ai.suggestion.user,model_mail_message_ai_suggestion,mail_manual_routing.group_lost_messages,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mail_message_ai_suggestion_user mail.message.ai.suggestion.user model_mail_message_ai_suggestion mail_manual_routing.group_lost_messages 1 1 1 1

View file

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form View -->
<record id="mail_message_ai_suggestion_view_form" model="ir.ui.view">
<field name="name">mail.message.ai.suggestion.form</field>
<field name="model">mail.message.ai.suggestion</field>
<field name="arch" type="xml">
<form string="Suggestion IA">
<header>
<button name="action_apply_suggestion" string="Appliquer cette suggestion"
type="object" class="oe_highlight"
attrs="{'invisible': [('is_applied', '=', True)]}"/>
<button name="action_mark_feedback" string="Marquer comme correcte"
type="object" class="btn-success"
context="{'feedback_type': 'positive'}"
attrs="{'invisible': [('user_feedback', '=', 'positive')]}"/>
<button name="action_mark_feedback" string="Marquer comme incorrecte"
type="object" class="btn-danger"
context="{'feedback_type': 'negative'}"
attrs="{'invisible': [('user_feedback', '=', 'negative')]}"/>
</header>
<sheet>
<div class="oe_title">
<label for="suggested_model" class="oe_edit_only"/>
<h1>
<field name="suggested_model" class="oe_inline"/> - <field name="suggested_record_name" class="oe_inline"/>
</h1>
</div>
<group>
<group>
<field name="message_id"/>
<field name="suggested_record_id"/>
<field name="confidence_score" widget="percentage"/>
</group>
<group>
<field name="is_applied"/>
<field name="user_feedback"/>
<field name="date_created"/>
</group>
</group>
<notebook>
<page string="Explication">
<field name="suggestion_reason"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Tree View -->
<record id="mail_message_ai_suggestion_view_tree" model="ir.ui.view">
<field name="name">mail.message.ai.suggestion.tree</field>
<field name="model">mail.message.ai.suggestion</field>
<field name="arch" type="xml">
<tree string="Suggestions IA" decoration-success="is_applied" decoration-info="user_feedback == 'positive'" decoration-danger="user_feedback == 'negative'">
<field name="message_id"/>
<field name="suggested_model"/>
<field name="suggested_record_name"/>
<field name="confidence_score" widget="percentage"/>
<field name="is_applied"/>
<field name="user_feedback"/>
<field name="date_created"/>
<button name="action_apply_suggestion" string="Appliquer" type="object" icon="fa-check" attrs="{'invisible': [('is_applied', '=', True)]}"/>
</tree>
</field>
</record>
<!-- Search View -->
<record id="mail_message_ai_suggestion_view_search" model="ir.ui.view">
<field name="name">mail.message.ai.suggestion.search</field>
<field name="model">mail.message.ai.suggestion</field>
<field name="arch" type="xml">
<search string="Rechercher des suggestions">
<field name="message_id"/>
<field name="suggested_model"/>
<field name="suggested_record_name"/>
<filter string="Appliquées" name="applied" domain="[('is_applied', '=', True)]"/>
<filter string="Non appliquées" name="not_applied" domain="[('is_applied', '=', False)]"/>
<filter string="Feedback positif" name="positive_feedback" domain="[('user_feedback', '=', 'positive')]"/>
<filter string="Feedback négatif" name="negative_feedback" domain="[('user_feedback', '=', 'negative')]"/>
<filter string="Score élevé (>75%)" name="high_score" domain="[('confidence_score', '>=', 0.75)]"/>
<group expand="0" string="Grouper par">
<filter string="Message" name="group_by_message" context="{'group_by': 'message_id'}"/>
<filter string="Modèle suggéré" name="group_by_model" context="{'group_by': 'suggested_model'}"/>
<filter string="Appliqué" name="group_by_applied" context="{'group_by': 'is_applied'}"/>
<filter string="Feedback" name="group_by_feedback" context="{'group_by': 'user_feedback'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_mail_message_ai_suggestion" model="ir.actions.act_window">
<field name="name">Suggestions IA</field>
<field name="res_model">mail.message.ai.suggestion</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_not_applied': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Aucune suggestion IA pour le moment
</p>
<p>
Les suggestions apparaîtront ici après l'analyse des messages non routés.
</p>
</field>
</record>
</odoo>

View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Extend mail.message form view -->
<record id="mail_message_view_form_ai_enhanced" model="ir.ui.view">
<field name="name">mail.message.form.ai.enhanced</field>
<field name="model">mail.message</field>
<field name="inherit_id" ref="mail_manual_routing.mail_message_view_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button string="Analyser avec IA"
name="action_analyze_with_ai"
type="object"
class="btn-primary"
attrs="{'invisible': ['|', ('is_unattached', '=', False), ('ai_analyzed', '=', True)]}"/>
</xpath>
<xpath expr="//field[@name='lost_comments']" position="after">
<field name="ai_analyzed" invisible="1"/>
<field name="ai_suggestion_count" invisible="1"/>
<field name="ai_top_suggestion_id" invisible="1"/>
<div class="alert alert-info" attrs="{'invisible': [('ai_suggestion_count', '=', 0)]}">
<div class="d-flex align-items-center">
<i class="fa fa-robot mr-2"/>
<span>L'IA suggère de router ce message vers : </span>
<field name="ai_top_suggestion_id" readonly="1" options="{'no_open': True}"/>
<button string="Appliquer"
name="action_apply_ai_suggestion"
type="object"
class="btn btn-sm btn-primary ml-2"
attrs="{'invisible': [('ai_top_suggestion_id', '=', False)]}"/>
<button string="Voir toutes les suggestions"
name="action_view_ai_suggestions"
type="object"
class="btn btn-sm btn-secondary ml-2"
attrs="{'invisible': [('ai_suggestion_count', '=', 0)]}"/>
</div>
<div attrs="{'invisible': [('ai_top_suggestion_id', '=', False)]}">
<field name="ai_top_suggestion_id.suggestion_reason" readonly="1" class="text-muted"/>
</div>
</div>
</xpath>
</field>
</record>
<!-- Extend mail.message tree view -->
<record id="mail_message_view_tree_ai_enhanced" model="ir.ui.view">
<field name="name">mail.message.tree.ai.enhanced</field>
<field name="model">mail.message</field>
<field name="inherit_id" ref="mail_manual_routing.mail_message_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='is_unattached']" position="after">
<field name="ai_analyzed"/>
<field name="ai_suggestion_count"/>
</xpath>
</field>
</record>
<!-- Extend mail.message search view -->
<record id="mail_message_view_search_ai_enhanced" model="ir.ui.view">
<field name="name">mail.message.search.ai.enhanced</field>
<field name="model">mail.message</field>
<field name="inherit_id" ref="mail_manual_routing.mail_message_view_search"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='unattached']" position="after">
<filter string="Analysés par IA" name="ai_analyzed" domain="[('ai_analyzed', '=', True)]"/>
<filter string="Non analysés par IA" name="not_ai_analyzed" domain="[('is_unattached', '=', True), ('ai_analyzed', '=', False)]"/>
<filter string="Avec suggestions" name="with_suggestions" domain="[('ai_suggestion_count', '>', 0)]"/>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Menu items -->
<menuitem id="menu_mail_message_ai_suggestion"
name="Suggestions IA"
parent="mail_manual_routing.menu_lost_messages"
action="action_mail_message_ai_suggestion"
sequence="20"
groups="mail_manual_routing.group_lost_messages"/>
</odoo>

View file

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_ai_enhanced" model="ir.ui.view">
<field name="name">res.config.settings.view.form.ai.enhanced</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@id='lost_messages_settings']" position="after">
<h2>Paramètres d'IA pour les messages perdus</h2>
<div class="row mt16 o_settings_container" id="ai_lost_messages_settings">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="ai_auto_analyze_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="ai_auto_analyze_enabled"/>
<div class="text-muted">
Analyser automatiquement les messages perdus avec l'IA
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="ai_auto_routing_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="ai_auto_routing_enabled"/>
<div class="text-muted">
Router automatiquement les messages selon les suggestions de l'IA
</div>
<div class="content-group" attrs="{'invisible': [('ai_auto_routing_enabled', '=', False)]}">
<div class="mt16">
<label for="ai_auto_routing_threshold" class="o_light_label"/>
<field name="ai_auto_routing_threshold" class="oe_inline" widget="percentage"/>
</div>
<div class="text-muted">
Score de confiance minimum pour le routage automatique
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="ai_learning_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="ai_learning_enabled"/>
<div class="text-muted">
Utiliser les retours utilisateurs pour améliorer les futures suggestions
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<label for="ai_models_allowed"/>
<field name="ai_models_allowed" placeholder="sale.order,project.project,res.partner"/>
<div class="text-muted">
Liste de modèles séparés par des virgules autorisés pour le routage IA
</div>
</div>
</div>
</div>
<h2>Intégration OpenWebUI</h2>
<div class="row mt16 o_settings_container" id="openwebui_settings">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="ai_use_openwebui"/>
</div>
<div class="o_setting_right_pane">
<label for="ai_use_openwebui"/>
<div class="text-muted">
Utiliser OpenWebUI pour l'analyse des messages au lieu de l'analyseur standard
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" attrs="{'invisible': [('ai_use_openwebui', '=', False)]}">
<div class="o_setting_right_pane">
<label for="ai_openwebui_endpoint"/>
<field name="ai_openwebui_endpoint" placeholder="http://localhost:3000/api/chat"/>
<div class="text-muted">
URL de l'API OpenWebUI pour l'analyse des messages
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" attrs="{'invisible': [('ai_use_openwebui', '=', False)]}">
<div class="o_setting_right_pane">
<label for="ai_openwebui_api_key"/>
<field name="ai_openwebui_api_key" password="True"/>
<div class="text-muted">
Clé API pour l'authentification avec OpenWebUI
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" attrs="{'invisible': [('ai_use_openwebui', '=', False)]}">
<div class="o_setting_right_pane">
<label for="ai_responsible_user_id"/>
<field name="ai_responsible_user_id"/>
<div class="text-muted">
Utilisateur qui recevra les activités pour les messages perdus analysés
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>