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] + "..."