| """ |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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() |
| |
| |
| 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) |
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 "" |
| |
| |
| text = re.sub(r'<\|[^|]+\|>', '', text) |
| text = re.sub(r'</s>', '', text) |
| |
| |
| 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) |
| |
| |
| text = re.sub(r'\n{3,}', '\n\n', text) |
| text = re.sub(r' {2,}', ' ', text) |
| 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"]) |
|
|
|
|
| |
| |
| |
|
|
| 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] |
| |
| |
| 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] + "..." |