File size: 7,766 Bytes
ea500bc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 | """
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] + "..." |