mirror of
https://github.com/Ladebeze66/llm_ticket3.git
synced 2025-12-16 03:47:49 +01:00
474 lines
20 KiB
Python
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 = {
|
|
' ': ' ', '<': '<', '>': '>', '&': '&',
|
|
'"': '"', ''': "'", '€': '€', '©': '©',
|
|
'®': '®', 'é': 'é', 'è': 'è', 'à': 'à',
|
|
'ç': 'ç', 'ê': 'ê', 'â': 'â', 'î': 'î',
|
|
'ô': 'ô', 'û': 'û'
|
|
}
|
|
|
|
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
|
|
} |