llm_ticket3/agents/agent_question_reponse.py
2025-04-02 09:01:55 +02:00

474 lines
20 KiB
Python

#!/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'</?[a-z]+[^>]*>', ' ', 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'<img[^>]+>', '[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 = {
'&nbsp;': ' ', '&lt;': '<', '&gt;': '>', '&amp;': '&',
'&quot;': '"', '&apos;': "'", '&euro;': '', '&copy;': '©',
'&reg;': '®', '&eacute;': 'é', '&egrave;': 'è', '&agrave;': 'à',
'&ccedil;': 'ç', '&ecirc;': 'ê', '&acirc;': 'â', '&icirc;': 'î',
'&ocirc;': 'ô', '&ucirc;': 'û'
}
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 <email@domaine.com>"
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
}