""" utils.py — Fonctions utilitaires Outils transversaux : détection de domaine, matching flou, nettoyage de texte. """ import re from typing import Optional, Dict, Tuple from fuzzywuzzy import fuzz from config import DOMAIN_KEYWORDS, FUZZY_MATCH_THRESHOLD, MIN_RESPONSE_WORDS # ───────────────────────────────────────────── # DÉTECTION DE DOMAINE # ───────────────────────────────────────────── def detect_domain(text: str) -> Optional[str]: """ Détecte le domaine technique d'une question. Algorithme : 1. Normalise le texte (minuscules, sans accents) 2. Compte les correspondances par domaine 3. Retourne le domaine avec le plus de correspondances Args: text: La question ou le message de l'utilisateur Returns: Le domaine détecté ('réseaux', 'cybersécurité', 'ia', 'data', 'cloud') ou None """ text_lower = text.lower() scores = {} for domain, keywords in DOMAIN_KEYWORDS.items(): score = sum(1 for kw in keywords if kw in text_lower) if score > 0: scores[domain] = score if not scores: return None return max(scores, key=scores.get) # ───────────────────────────────────────────── # MATCHING FLOU DANS LA BASE DE CONNAISSANCES # ───────────────────────────────────────────── def find_best_match(question: str, knowledge_base: Dict[str, str], threshold: int = FUZZY_MATCH_THRESHOLD) -> Tuple[Optional[str], int]: """ Trouve la meilleure correspondance dans la base de connaissances. Utilise fuzzywuzzy pour comparer la question avec toutes les entrées. Combine ratio simple et ratio partiel pour une meilleure robustesse. Args: question: La question de l'utilisateur knowledge_base: Dict {question: réponse} threshold: Score minimum pour accepter une correspondance (0-100) Returns: Tuple (réponse, score) ou (None, 0) si aucune correspondance """ if not knowledge_base: return None, 0 best_answer = None best_score = 0 question_lower = question.lower().strip() for kb_question, answer in knowledge_base.items(): kb_lower = kb_question.lower().strip() # Score combiné : ratio exact + ratio partiel ratio = fuzz.ratio(question_lower, kb_lower) partial = fuzz.partial_ratio(question_lower, kb_lower) token_sort = fuzz.token_sort_ratio(question_lower, kb_lower) # Pondération : on favorise la correspondance token pour les questions longues combined_score = max(ratio, partial, token_sort) if combined_score > best_score: best_score = combined_score best_answer = answer if best_score >= threshold: return best_answer, best_score return None, best_score # ───────────────────────────────────────────── # NETTOYAGE ET VALIDATION DES RÉPONSES # ───────────────────────────────────────────── def clean_response(text: str) -> str: """ Nettoie une réponse générée par le LLM. Supprime : - Les artefacts de tokenisation - Les répétitions de la question - Les balises de format non souhaitées - Les espaces excessifs Args: text: Le texte brut généré par le LLM Returns: Le texte nettoyé """ if not text: return "" # Supprimer les balises ChatML résiduelles text = re.sub(r'<\|[^|]+\|>', '', text) text = re.sub(r'', '', text) # Supprimer les préfixes habituels générés prefixes_to_remove = [ r'^(assistant|Assistant)\s*:\s*', r'^(réponse|Réponse)\s*:\s*', r'^(WENDAA AI)\s*:\s*', ] for pattern in prefixes_to_remove: text = re.sub(pattern, '', text, flags=re.IGNORECASE) # Normaliser les espaces text = re.sub(r'\n{3,}', '\n\n', text) # Max 2 sauts de ligne consécutifs text = re.sub(r' {2,}', ' ', text) # Max 1 espace consécutif text = text.strip() return text def is_valid_response(text: str, min_words: int = MIN_RESPONSE_WORDS) -> bool: """ Vérifie si une réponse est assez substantielle pour être présentée. Args: text: Le texte de la réponse min_words: Nombre minimum de mots requis Returns: True si la réponse est valide """ if not text or not text.strip(): return False word_count = len(text.split()) return word_count >= min_words def format_error_message(error_type: str) -> str: """ Retourne un message d'erreur convivial selon le type d'erreur. Args: error_type: Type d'erreur ('model_unavailable', 'timeout', 'generic') Returns: Message d'erreur formaté """ messages = { "model_unavailable": ( "⚠️ Les modèles IA ne sont pas disponibles pour le moment. " "Je cherche dans ma base de connaissances..." ), "timeout": ( "⏱️ La génération a pris trop de temps. Essayez une question plus courte " "ou reformulez votre question." ), "context_too_long": ( "📝 Le contexte de conversation est trop long. " "Je vais résumer les échanges précédents." ), "generic": ( "❌ Une erreur inattendue s'est produite. " "Veuillez réessayer ou reformuler votre question." ), } return messages.get(error_type, messages["generic"]) # ───────────────────────────────────────────── # EXTRACTION DE MOTS-CLÉS # ───────────────────────────────────────────── STOPWORDS_FR = { "qu", "est", "ce", "que", "c", "un", "une", "les", "la", "le", "du", "des", "et", "ou", "mais", "donc", "or", "ni", "car", "pour", "dans", "sur", "avec", "par", "à", "de", "en", "je", "tu", "il", "elle", "nous", "vous", "ils", "elles", "me", "te", "se", "y", "comment", "quoi", "quel", "quelle", "quels", "quelles", "this", "that", "the", "a", "an", "is", "are", "was", "were", } def extract_keywords(text: str, max_keywords: int = 8) -> str: """ Extrait les mots-clés significatifs d'un texte. Args: text: Le texte source max_keywords: Nombre maximum de mots-clés à retourner Returns: String de mots-clés séparés par des espaces """ words = re.findall(r'\b[a-zA-ZÀ-ÿ]{3,}\b', text.lower()) keywords = [w for w in words if w not in STOPWORDS_FR] # Déduplique en conservant l'ordre seen = set() unique_keywords = [] for kw in keywords: if kw not in seen: seen.add(kw) unique_keywords.append(kw) return " ".join(unique_keywords[:max_keywords]) def truncate_text(text: str, max_chars: int = 200) -> str: """Tronque un texte avec ellipsis si nécessaire.""" if len(text) <= max_chars: return text return text[:max_chars - 3] + "..."