WENDAA-AI / utils.py
Vieuxwalo's picture
Create utils.py
ea500bc verified
"""
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'</s>', '', 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] + "..."