#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Agent pour extraire et structurer les questions et réponses des tickets de support. """ import os import re from typing import Dict, List, Any, Optional from .agent_base import Agent from llm import Mistral from post_process import normaliser_accents class AgentQuestionReponse(Agent): """ Agent qui extrait les questions et réponses des messages de support. """ def __init__(self, api_key: Optional[str] = None): """ Initialise l'agent d'extraction de questions et réponses. Args: api_key: Clé API pour le LLM """ super().__init__("AgentQuestionReponse") self.llm = Mistral(api_key=api_key) # Configuration par défaut du LLM default_system_prompt = """ Vous êtes un expert en analyse de conversations de support technique. Votre mission est d'identifier avec précision: 1. Le rôle de chaque intervenant (client ou support technique) 2. La nature de chaque message (question, réponse, information additionnelle) 3. Le contenu essentiel de chaque message en éliminant les formules de politesse, signatures, mentions légales et autres éléments non pertinents Pour l'identification client/support: - Support: Signatures avec noms d'entreprise fournissant le logiciel, domaines email comme @cbao.fr, @odoo.com, mentions "support technique", etc. - Client: Utilisateurs finaux qui signalent des problèmes ou posent des questions Pour la classification en question/réponse: - Questions: Demandes explicites (avec "?"), demandes implicites de résolution de problèmes, descriptions de bugs ou dysfonctionnements - Réponses: Explications techniques, solutions proposées, instructions fournies par le support Concentrez-vous uniquement sur le contenu technique utile en ignorant tous les éléments superflus qui n'apportent pas d'information sur le problème ou sa solution. """ self.configurer_llm( system_prompt=default_system_prompt, temperature=0.3, # Basse température pour une extraction précise max_tokens=2000 ) def _nettoyer_contenu(self, texte: str) -> str: """ Nettoie le contenu en supprimant signatures, mentions légales, etc. Args: texte: Texte brut à nettoyer Returns: Texte nettoyé des éléments non pertinents """ # Si l'entrée n'est pas une chaîne, convertir en chaîne ou retourner vide if not isinstance(texte, str): if texte is None: return "" try: texte = str(texte) except: return "" # Détection de contenu HTML contient_html = bool(re.search(r'<[a-z]+[^>]*>', texte, re.IGNORECASE)) # Supprimer les balises HTML - approche plus robuste try: # Première passe - balises standard texte_nettoye = re.sub(r']*>', ' ', texte, flags=re.IGNORECASE) # Deuxième passe - balises restantes, y compris les mal formées texte_nettoye = re.sub(r'<[^>]*>', ' ', texte_nettoye) # Troisième passe pour les balises qui pourraient avoir échappé texte_nettoye = re.sub(r'<[^>]*$', ' ', texte_nettoye) # Balises incomplètes à la fin except Exception as e: self.ajouter_historique("erreur_nettoyage_html", "Échec", str(e)) texte_nettoye = texte # Remplacer les références aux images texte_nettoye = re.sub(r'\[Image:[^\]]+\]', '[Image]', texte_nettoye) texte_nettoye = re.sub(r']+>', '[Image]', texte_nettoye, flags=re.IGNORECASE) # Supprimer les éléments courants non pertinents patterns_a_supprimer = [ r'Cordialement,[\s\S]*?$', r'Bien cordialement,[\s\S]*?$', r'Bonne réception[\s\S]*?$', r'À votre disposition[\s\S]*?$', r'Support technique[\s\S]*?$', r'L\'objectif du Support Technique[\s\S]*?$', r'Notre service est ouvert[\s\S]*?$', r'Dès réception[\s\S]*?$', r'Confidentialité[\s\S]*?$', r'Ce message électronique[\s\S]*?$', r'Droit à la déconnexion[\s\S]*?$', r'Afin d\'assurer une meilleure traçabilité[\s\S]*?$', r'tél\s*:\s*[\d\s\+]+', r'mobile\s*:\s*[\d\s\+]+', r'www\.[^\s]+\.[a-z]{2,3}', r'\*{10,}.*?\*{10,}', # Lignes de séparation avec astérisques r'----.*?----', # Lignes de séparation avec tirets ] for pattern in patterns_a_supprimer: texte_nettoye = re.sub(pattern, '', texte_nettoye, flags=re.IGNORECASE) # Supprimer les lignes multiples vides texte_nettoye = re.sub(r'\n\s*\n', '\n', texte_nettoye) # Supprimer les espaces multiples texte_nettoye = re.sub(r'\s+', ' ', texte_nettoye) # Convertir les entités HTML html_entities = { ' ': ' ', '<': '<', '>': '>', '&': '&', '"': '"', ''': "'", '€': '€', '©': '©', '®': '®', 'é': 'é', 'è': 'è', 'à': 'à', 'ç': 'ç', 'ê': 'ê', 'â': 'â', 'î': 'î', 'ô': 'ô', 'û': 'û' } for entity, char in html_entities.items(): texte_nettoye = texte_nettoye.replace(entity, char) # Normaliser les caractères accentués try: texte_nettoye = normaliser_accents(texte_nettoye) except Exception as e: self.ajouter_historique("erreur_normalisation_accents", "Échec", str(e)) return texte_nettoye.strip() def _detecter_role(self, message: Dict[str, Any]) -> str: """ Détecte si un message provient du client ou du support. Args: message: Dictionnaire contenant les informations du message Returns: "Client" ou "Support" """ # Vérifier le champ 'role' s'il existe déjà if "role" in message and message["role"] in ["Client", "Support"]: return message["role"] # Indices de support dans l'email domaines_support = ["@cbao.fr", "@odoo.com", "support@", "ticket.support"] indices_nom_support = ["support", "cbao", "technique", "odoo"] email = message.get("email_from", "").lower() # Nettoyer le format "Nom " if "<" in email and ">" in email: match = re.search(r'<([^>]+)>', email) if match: email = match.group(1).lower() # Vérifier le domaine email if any(domaine in email for domaine in domaines_support): return "Support" # Vérifier le nom d'auteur auteur = "" if "author_id" in message and isinstance(message["author_id"], list) and len(message["author_id"]) > 1: auteur = str(message["author_id"][1]).lower() elif "auteur" in message: auteur = str(message["auteur"]).lower() if any(indice in auteur for indice in indices_nom_support): return "Support" # Par défaut, considérer comme client return "Client" def _analyser_messages_llm(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]: """ Utilise le LLM pour analyser les messages et en extraire les questions/réponses. Args: messages: Liste des messages à analyser Returns: Analyse des messages et paires de questions/réponses """ self.ajouter_historique("analyse_messages_llm", f"{len(messages)} messages", "Analyse en cours...") # Vérifier s'il y a des messages à analyser if len(messages) == 0: self.ajouter_historique("analyse_messages_llm_erreur", "Aucun message", "La liste des messages est vide") return { "success": False, "error": "Aucun message à analyser", "messages_analyses": [], "paires_qr": [] } # Vérifier si nous n'avons qu'un seul message (probablement le message du système) if len(messages) == 1: message_unique = messages[0] role = message_unique.get("role", "") # Si c'est un message système, nous n'avons pas de vraie conversation if role == "system": self.ajouter_historique("analyse_messages_llm_erreur", "Un seul message système", "Pas de conversation à analyser") return { "success": True, "messages_analyses": [], "paires_qr": [] } try: # Préparation des messages pour le LLM messages_for_llm = [] for i, msg in enumerate(messages): # Inclure uniquement les messages de type Client ou Support role = msg.get("role", "") if role not in ["Client", "Support"]: continue # Formater le message pour le LLM messages_for_llm.append({ "numero": i + 1, "role": role, "date": msg.get("date", ""), "contenu": msg.get("body", "") }) # S'il n'y a aucun message Client ou Support if not messages_for_llm: self.ajouter_historique("analyse_messages_llm_erreur", "Aucun message pertinent", "Pas de message Client ou Support à analyser") return { "success": True, "messages_analyses": [], "paires_qr": [] } # Utiliser la nouvelle méthode analyze_messages_json de Mistral resultat = self.llm.analyze_messages_json(messages_for_llm) if "error" in resultat: self.ajouter_historique("analyse_messages_llm_erreur", "Erreur API", resultat["error"]) return { "success": False, "error": resultat["error"], "messages_analyses": [], "paires_qr": [] } contenu = resultat.get("content", "") self.ajouter_historique("analyse_messages_llm_resultat", "Analyse complétée", contenu[:200] + "...") # Traiter la réponse pour extraire les messages analysés messages_analyses = [] pattern_messages = r"MESSAGE (\d+):\s*- Rôle: (Client|Support)\s*- Type: (Question|Réponse|Information)\s*- Contenu essentiel: (.*?)(?=MESSAGE \d+:|PAIRE \d+:|$)" for match in re.finditer(pattern_messages, contenu, re.DOTALL): num = int(match.group(1)) role = match.group(2) type_msg = match.group(3) contenu_essentiel = match.group(4).strip() # Trouver le message correspondant msg_idx = num - 1 msg_id = "" msg_date = "" if 0 <= msg_idx < len(messages_for_llm): original_idx = messages_for_llm[msg_idx]["numero"] - 1 if 0 <= original_idx < len(messages): msg_id = messages[original_idx].get("id", "") or messages[original_idx].get("ID", "") msg_date = messages[original_idx].get("date", "") messages_analyses.append({ "id": msg_id, "date": msg_date, "role": role, "type": type_msg, "contenu": contenu_essentiel }) # Extraire les paires QR paires_qr = [] pattern_paires = r"PAIRE (\d+):\s*- Question \((Client|Support)\): (.*?)(?:\s*- Réponse \((Client|Support)\): (.*?))?(?=PAIRE \d+:|$)" for match in re.finditer(pattern_paires, contenu, re.DOTALL): num = match.group(1) q_role = match.group(2) question = match.group(3).strip() r_role = match.group(4) if match.group(4) else "" reponse = match.group(5).strip() if match.group(5) else "" paires_qr.append({ "numero": num, "question": { "role": q_role, "contenu": question }, "reponse": { "role": r_role, "contenu": reponse } if reponse else None }) # Ne pas générer de paires artificielles si le LLM n'en a pas détecté if not paires_qr: self.ajouter_historique("analyse_paires_qr", "Aucune paire", "Le LLM n'a détecté aucune paire question/réponse") return { "success": True, "messages_analyses": messages_analyses, "paires_qr": paires_qr } except Exception as e: self.ajouter_historique("analyse_messages_llm_erreur", f"{len(messages)} messages", str(e)) return { "success": False, "error": str(e), "messages_analyses": [], "paires_qr": [] } def _generer_tableau_markdown(self, paires_qr: List[Dict[str, Any]]) -> str: """ Génère un tableau Markdown avec les questions et réponses. Args: paires_qr: Liste de paires question/réponse Returns: Tableau Markdown formaté """ # Créer le tableau markdown = ["# Analyse des Questions et Réponses\n"] markdown.append("| Question | Réponse |") markdown.append("|---------|---------|") for paire in paires_qr: question = paire.get("question", {}) reponse = paire.get("reponse", {}) q_role = question.get("role", "Client") q_contenu = question.get("contenu", "") # Normaliser le contenu des questions pour corriger les accents q_contenu = normaliser_accents(q_contenu) if reponse: r_role = reponse.get("role", "Support") r_contenu = reponse.get("contenu", "") # Normaliser le contenu des réponses pour corriger les accents r_contenu = normaliser_accents(r_contenu) markdown.append(f"| **{q_role}**: {q_contenu} | **{r_role}**: {r_contenu} |") else: markdown.append(f"| **{q_role}**: {q_contenu} | *Pas de réponse* |") # Ajouter les informations sur les paramètres LLM utilisés markdown.append("\n## Paramètres LLM utilisés\n") params = self.generer_rapport_parametres() markdown.append(f"- **Type de LLM**: {params['llm_type']}") markdown.append(f"- **Modèle**: {params['parametres'].get('model', 'Non spécifié')}") markdown.append(f"- **Température**: {params['parametres'].get('temperature', 'Non spécifiée')}") markdown.append(f"- **Tokens max**: {params['parametres'].get('max_tokens', 'Non spécifié')}") if params['parametres_modifies']: markdown.append("\n**Paramètres modifiés durant l'analyse:**") for param, valeur in params['parametres_modifies'].items(): if param != 'system_prompt': # Exclure le system_prompt car trop long markdown.append(f"- **{param}**: {valeur}") # Normaliser tout le contenu markdown final pour s'assurer que tous les accents sont corrects return normaliser_accents("\n".join(markdown)) def executer(self, messages_data: List[Dict[str, Any]], output_path: Optional[str] = None) -> Dict[str, Any]: """ Analyse les messages pour extraire les questions et réponses. Args: messages_data: Liste des messages du ticket output_path: Chemin où sauvegarder le tableau Markdown (optionnel) Returns: Résultats de l'analyse avec le tableau Markdown """ self.ajouter_historique("debut_execution", f"{len(messages_data)} messages", "Début de l'analyse") try: # Préparation des messages messages_prepares = [] for msg in messages_data: # Nettoyer le contenu contenu = msg.get("body", "") or msg.get("contenu", "") contenu_nettoye = self._nettoyer_contenu(contenu) # Détecter le rôle role = self._detecter_role(msg) # Ajouter le message préparé si non vide après nettoyage if contenu_nettoye.strip(): messages_prepares.append({ "id": msg.get("id", "") or msg.get("ID", ""), "date": msg.get("date", ""), "author_id": msg.get("author_id", []), "email_from": msg.get("email_from", ""), "role": role, "body": contenu_nettoye }) # Trier par date si disponible messages_prepares.sort(key=lambda x: x.get("date", "")) # Analyser avec le LLM resultats_analyse = self._analyser_messages_llm(messages_prepares) # Générer le tableau Markdown avec normalisation des accents tableau_md = self._generer_tableau_markdown(resultats_analyse.get("paires_qr", [])) # Dernière vérification pour s'assurer que les accents sont normalisés tableau_md = normaliser_accents(tableau_md) # Sauvegarder le tableau si un chemin est fourni if output_path: try: # Créer le dossier parent si nécessaire os.makedirs(os.path.dirname(output_path), exist_ok=True) with open(output_path, 'w', encoding='utf-8') as f: f.write(tableau_md) self.ajouter_historique("sauvegarde_tableau", output_path, "Tableau sauvegardé") except Exception as e: self.ajouter_historique("erreur_sauvegarde", output_path, str(e)) # Préparer le résultat resultat = { "success": resultats_analyse.get("success", False), "messages_analyses": resultats_analyse.get("messages_analyses", []), "paires_qr": resultats_analyse.get("paires_qr", []), "nb_questions": len(resultats_analyse.get("paires_qr", [])), "nb_reponses": sum(1 for p in resultats_analyse.get("paires_qr", []) if p.get("reponse")), "tableau_md": tableau_md, "parametres_llm": self.generer_rapport_parametres() } self.ajouter_historique("fin_execution", "Analyse terminée", f"{resultat['nb_questions']} questions, {resultat['nb_reponses']} réponses") return resultat except Exception as e: erreur = f"Erreur lors de l'analyse: {str(e)}" self.ajouter_historique("erreur_execution", "Exception", erreur) return { "success": False, "error": erreur }