import streamlit as st import pickle import pandas as pd import numpy as np import torch import os import io import re from transformers import AutoTokenizer, AutoModel from sklearn.base import BaseEstimator, TransformerMixin from sklearn.ensemble import RandomForestClassifier from sklearn.svm import SVC try: import lightgbm as lgb except ImportError: pass # Cache global de niveau module pour éviter de charger plusieurs fois le tokenizer et le modèle _TOKENIZER_CACHE = {} _MODEL_CACHE = {} def _get_tokenizer(model_name): """Récupère ou crée un tokenizer depuis le cache""" if model_name not in _TOKENIZER_CACHE: _TOKENIZER_CACHE[model_name] = AutoTokenizer.from_pretrained(model_name) return _TOKENIZER_CACHE[model_name] def _get_model(model_name, device="cpu"): """Récupère ou crée un modèle depuis le cache""" if model_name not in _MODEL_CACHE: _MODEL_CACHE[model_name] = AutoModel.from_pretrained(model_name).to(device) return _MODEL_CACHE[model_name] # --- 0. DÉFINITION DE LA CLASSE CUSTOM (Indispensable pour charger le PKL) --- class LLMFeatureExtractor(BaseEstimator, TransformerMixin): def __init__(self, model_name='distilbert-base-multilingual-cased', max_length=128): self.model_name = model_name self.max_length = max_length self.tokenizer = _get_tokenizer(model_name) self.model = _get_model(model_name, "cpu") def fit(self, X, y=None): return self def transform(self, X): m_len = getattr(self, 'max_length', 128) texts = X.tolist() if hasattr(X, 'tolist') else list(X) texts = [str(t)[:1000] for t in texts] inputs = self.tokenizer( texts, padding=True, truncation=True, max_length=m_len, return_tensors="pt" ).to("cpu") with torch.no_grad(): outputs = self.model(**inputs) return outputs.last_hidden_state[:, 0, :].detach().numpy() def __getstate__(self): state = self.__dict__.copy() if 'model' in state: del state['model'] if 'tokenizer' in state: del state['tokenizer'] return state def __setstate__(self, state): self.__dict__.update(state) self.tokenizer = _get_tokenizer(self.model_name) self.model = _get_model(self.model_name, "cpu") # --- FONCTION DE NETTOYAGE DE TEXTE FRANÇAIS --- def nettoyer_texte_francais(texte, retour_stats=False): """ Nettoyage avancé du texte français pour les tickets de support - Suppression des tags HTML - Suppression des emails et adresses web - Suppression des signatures courantes - Normalisation des caractères spéciaux français - Normalisation du vocabulaire IT spécifique - Suppression des espaces multiples - Optionally returns cleaning statistics """ if not isinstance(texte, str): if retour_stats: return "", {"taux_suppression": 0, "operations": []} return "" # Conversion en chaîne de caractères texte_original = str(texte) texte = texte_original operations_appliques = [] # 1. Suppression des tags HTML texte_avant = texte texte = re.sub(r'<[^>]*>', ' ', texte) if texte_avant != texte: operations_appliques.append("suppression_html") # 2. Suppression des emails texte_avant = texte texte = re.sub(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', ' ', texte) if texte_avant != texte: operations_appliques.append("suppression_emails") # 3. Suppression des URLs texte_avant = texte texte = re.sub(r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', ' ', texte) texte = re.sub(r'www\.(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', ' ', texte) if texte_avant != texte: operations_appliques.append("suppression_urls") # 4. Normalisation du vocabulaire IT spécifique (avant nettoyage général) it_patterns = { r'\bINC\d+\b': '[ID_TICKET]', # Incidents Freshservice/ServiceNow r'\bREQ\d+\b': '[ID_DEMANDE]', # Requests r'\b(CHG|CRQ)\d+\b': '[ID_CHANGE]', # Changes r'\b(?:PROB|PROBLEME|ISSUE)\s*[#:]?\s*\w+': '[ID_PROBLEME]', # Références problèmes r'v\d+\.\d+\.\d+': '[VERSION]', # Versions logicielles r'\b(SAP|ORACLE|SQLSERVER|WINDOWS|LINUX|DOCKER|KUBERNETES)\b': '[SYSTEME]', # Systèmes r'\b(?:API|REST|JSON|XML|HTTP|HTTPS|FTP|SSH)\b': '[TECHNO]', # Technologies r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b': '[IP_ADDRESS]', # Adresses IP r'\b[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}\b': '[UUID]', # UUIDs } texte_avant = texte for pattern, remplacement in it_patterns.items(): texte = re.sub(pattern, remplacement, texte, flags=re.IGNORECASE) if texte_avant != texte: operations_appliques.append("normalisation_it") # 5. Suppression des signatures courantes dans les emails professionnels (amélioré avec contexte) signatures_patterns = [ r'(?:^|\n\s*)(?:cordialement|bien cordialement|meilleures? salutations|salutations distinguées|respectueusement)[^\n]*(?:\n\s*$|\n\s*\n)', r'(?:^|\n\s*)(?:envoyé de|sent from|envoyé depuis|send from).*?(?:\n\s*$|\n\s*\n)', r'(?:^|\n\s*)(?:ce message et[^\n]*confidentiel[^\n]*)(?:\n\s*$|\n\s*\n)', # Disclaimers r'--\s*\n\s*$', # Ligne de séparation de signature avec saut de ligne r'_{3,}\s*\n\s*$', # Ligne de soulignement longue r'\n\s*[=-]{3,}\s*\n\s*$', # Séparateurs de type === ou --- ] texte_avant = texte for pattern in signatures_patterns: texte = re.sub(pattern, ' ', texte, flags=re.IGNORECASE) if texte_avant != texte: operations_appliques.append("suppression_signatures") # 6. Normalisation des caractères spéciaux français replacements = { 'à': 'a', 'â': 'a', 'ä': 'a', 'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e', 'î': 'i', 'ï': 'i', 'ô': 'o', 'ö': 'o', 'ù': 'u', 'û': 'u', 'ü': 'u', 'ç': 'c', 'œ': 'oe', 'æ': 'ae', 'ø': 'o', 'À': 'A', 'Â': 'A', 'Ä': 'A', 'É': 'E', 'È': 'E', 'Ê': 'E', 'Ë': 'E', 'Î': 'I', 'Ï': 'I', 'Ô': 'O', 'Ö': 'O', 'Ù': 'U', 'Û': 'U', 'Ü': 'U', 'Ç': 'C', 'Œ': 'OE', 'Æ': 'AE', 'Ø': 'O' } texte_avant = texte for accent, normal in replacements.items(): texte = texte.replace(accent, normal) if texte_avant != texte: operations_appliques.append("normalisation_accents") # 7. Suppression des caractères spéciaux non alphanumériques (garder espaces et ponctuation de base) texte_avant = texte texte = re.sub(r'[^\w\s\.\,\!\?\-\:\;]', ' ', texte) if texte_avant != texte: operations_appliques.append("suppression_caracteres_speciaux") # 8. Normalisation des espaces multiples texte_avant = texte texte = re.sub(r'\s+', ' ', texte) if texte_avant != texte: operations_appliques.append("normalisation_espaces") # 9. Suppression des espaces en début et fin texte_avant = texte texte = texte.strip() if texte_avant != texte: operations_appliques.append("trim") # Calcul du taux de suppression taux_suppression = 0 if len(texte_original) > 0: taux_suppression = (len(texte_original) - len(texte)) / len(texte_original) * 100 if retour_stats: stats = { "longueur_originale": len(texte_original), "longueur_nettoye": len(texte), "taux_suppression": round(taux_suppression, 2), "operations": operations_appliques } return texte, stats return texte # Sécurité pour le désarchivage du modèle sur CPU class CPU_Unpickler(pickle.Unpickler): def find_class(self, module, name): # Force Python à chercher LLMFeatureExtractor dans le module app if name == 'LLMFeatureExtractor': import app return app.LLMFeatureExtractor if module == 'torch.storage' and name == '_load_from_bytes': return lambda b: torch.load(io.BytesIO(b), map_location='cpu') return super().find_class(module, name) # --- 1. CHARGEMENT DYNAMIQUE --- @st.cache_resource def charger_modele_et_data(max_retries=3): import time # Chemins robustes pour environnement local et cloud base_path = os.path.dirname(__file__) MODELE_PATH = os.path.join(base_path, 'modele_classification_taln_llm.pkl') MTTR_PATH = os.path.join(base_path, 'mttr_moyennes.pkl') for attempt in range(max_retries): try: with open(MODELE_PATH, 'rb') as file: modele = CPU_Unpickler(file).load() mttr_moyens = {} if os.path.exists(MTTR_PATH): with open(MTTR_PATH, 'rb') as f: mttr_moyens = pickle.load(f) return modele, mttr_moyens except Exception as e: if attempt == max_retries - 1: print(f"Erreur de chargement finale après {max_retries} tentatives : {e}") return None, None wait_time = 2 ** attempt print(f"Tentative de chargement {attempt + 1}/{max_retries} échouée. Nouvelle tentative dans {wait_time}s... Erreur: {e}") time.sleep(wait_time) # Chargement global sécurisé MODELE, MTTR_MOYENS = charger_modele_et_data() # Sécurité si le chargement échoue pour éviter le crash .get() if MTTR_MOYENS is None: MTTR_MOYENS = {} # --- 2. FONCTION DE PRÉDICTION --- def predire_ticket(objet: str, description: str): # Nettoyage avancé du texte français objet_clean = nettoyer_texte_francais(str(objet)) description_clean = nettoyer_texte_francais(str(description)) texte_complet_saisi = objet_clean + " " + description_clean if not texte_complet_saisi.strip(): return "Saisie vide", 0.0, 0.0 X_inference = pd.Series([texte_complet_saisi]) try: probabilites = MODELE.predict_proba(X_inference)[0] confiance = np.max(probabilites) prediction_brute = MODELE.classes_[np.argmax(probabilites)] except Exception as e: prediction_brute = "Erreur" confiance = 0.0 # On affiche toujours la prédiction du modèle pour la démo # (Anciennement il y avait un seuil de 65% de confiance) if str(prediction_brute).upper() == "AUTRES": categorie_finale = "À qualifier (Hors catalogue)" else: categorie_finale = prediction_brute mttr_predit = MTTR_MOYENS.get(prediction_brute, 0.0) return categorie_finale, mttr_predit, confiance # --- 3. INTERFACE STREAMLIT --- # Cette partie ne s'exécute QUE si on lance 'streamlit run app.py' if __name__ == "__main__": st.set_page_config(page_title="GDM Aide au RUN V4", layout="wide", page_icon="👗") st.markdown(""" """, unsafe_allow_html=True) st.title("👗 Grain de Malice - Aide au RUN") st.subheader("Routage Intelligent par Application") # STORYTELLING POUR LE JURY with st.expander("📖 Contexte & Problématique Métier (Cliquez pour lire)"): st.markdown(""" **1. Le Constat (Pain Point)** En analysant plus de **13 600 tickets** d'incidents chez Grain de Malice (Avril 2023 à Mai 2026), nous avons constaté que le support de Niveau 1 passe un temps considérable à **lire chaque ticket** pour décider manuellement vers quelle équipe l'envoyer (RUN, Data/BI, Commerce, Finance...). **2. La Solution IA : Inférence Hybride & Rééquilibrage** Cette application supprime le triage manuel via une pipeline optimisée : - **Extraction Sémantique (LLM) :** Le texte est vectorisé par **DistilBERT**. Nous extrayons le tenseur **[CLS]** de la dernière couche cachée pour capturer le contexte profond du problème. - **Traitement du Biais (SMOTE) :** Le dataset historique étant fortement déséquilibré, nous appliquons l'algorithme de suréchantillonnage SMOTE. Cela permet à l'IA d'apprendre et de détecter équitablement les classes minoritaires (Data, Finance, Commerce). - **Classification (ML) :** Ces embeddings rééquilibrés nourrissent un modèle **LightGBM**, choisi pour sa rapidité d'inférence et sa précision sur des frontières de décision complexes. - **Confiance (Softmax) :** L'IA produit une distribution de probabilités sur 5 classes, garantissant un routage fiable. **3. L'Industrialisation (Cloud & Sécurité)** - **Poids Plume :** Grâce à une surcharge de la sérialisation (`__getstate__`), l'artefact passe de **540 Mo à 2 Mo**. Les poids du LLM sont instanciés dynamiquement au démarrage. - **Data Privacy :** Entraînement 100% on-premise (GPU RTX 3060). Aucune donnée PII sensible n'est exposée sur internet. """) with st.sidebar: # 1. Logo et En-tête if os.path.exists("logo_gdm.webp"): st.image("logo_gdm.webp", width=150) st.header("🚀 Architecture MLOps") st.success("✅ **Modèle NLP :** DistilBERT Multilingual") st.info("🎯 **Routage :** 5 Pôles de Résolution") st.markdown(""" * **MONITORING** : Alertes auto (Opcon, Nuit) * **RUN** : Support applicatif quotidien * **DATA & BI** : Snowflake, Reporting * **COMMERCE & MAGASINS** : Caisse, TPE, Stock * **FINANCE & SUPPORT** : Outils de gestion, RH, Web """) st.markdown("---") st.subheader("⏱️ MTTR Médian par Pôle") st.caption("Le MTTR (Mean Time To Resolution) représente le délai médian typique pour clôturer un incident dans chaque pôle, basé sur l'historique des 3 dernières années.") if MTTR_MOYENS: df_mttr = pd.DataFrame([ {"Pôle": k, "Délai": f"{v:.1f} h"} for k, v in sorted(MTTR_MOYENS.items(), key=lambda x: x[1]) if v is not None ]) st.table(df_mttr) st.markdown("---") st.subheader("🛠️ Déploiement & Industrialisation") with st.expander("🌐 Intégration ITSM (FastAPI)"): st.write(""" Le modèle est découplé via une API RESTful (`api.py`), prête pour une interopérabilité (webhooks) avec **ServiceNow**, **GLPI** ou **Jira**. """) with st.expander("⚡ Optimisation du Poids"): st.write(""" Utilisation d'un **Custom Unpickler** : l'artefact final ne pèse que **2 Mo**. Les poids constants du LLM sont mis en cache au build Docker. """) with st.expander("☁️ Cloud & Privacy"): st.write(""" Déploiement conteneurisé (**Docker**) sur Hugging Face Spaces. Inférence temps réel sécurisée. """) st.caption("Data Science & MLOps - Grain de Malice © 2026") st.markdown("---") col_input1, col_input2 = st.columns(2) with col_input1: objet_saisi = st.text_input("📍 Objet du ticket :") with col_input2: description_saisie = st.text_area("📝 Description détaillée :") if st.button("🚀 Lancer le Diagnostic IA"): if MODELE is None: st.error("❌ Erreur de chargement : Le modèle d'IA est introuvable ou corrompu sur le serveur.") elif not objet_saisi and not description_saisie: st.warning("Veuillez saisir des informations.") else: with st.spinner('Analyse sémantique en cours...'): categorie, mttr, confiance = predire_ticket(objet_saisi, description_saisie) st.markdown("### ✅ Résultat du Diagnostic") res1, res2, res3 = st.columns([2, 1, 1]) with res1: st.metric("Pôle de Résolution", categorie) with res2: st.metric("MTTR Médian", f"{mttr:.1f} h" if mttr else "N/A", help="Délai médian historique pour résoudre un ticket dans ce pôle.") with res3: st.metric("Confiance IA", f"{confiance*100:.1f} %", help="Probabilité (Softmax) générée par LightGBM.") st.caption("ℹ️ **Comment la confiance est-elle calculée ?** L'algorithme (LightGBM) analyse le texte et calcule une probabilité pour chaque pôle (la somme fait 100%). Le score affiché est la probabilité du pôle gagnant. Plus le texte contient de mots-clés spécifiques à ce pôle, plus la confiance s'approche de 100%.") # DESCRIPTIONS DES PÔLES (Pour aider le jury à comprendre) DESCRIPTIONS_POLES = { "MONITORING": "Alertes automatisées (Opcon, Chaîne de Nuit). Surveillance 24/7 des flux et des jobs planifiés.", "RUN": "Support applicatif quotidien. Incidents remontés par les magasins et le siège sur les outils métier.", "DATA & BI": "Équipe Data/BI. Incidents liés à Snowflake, aux reportings, aux tableaux de bord et à la Business Intelligence.", "COMMERCE & MAGASINS": "Équipes Commerce & Logistique. Incidents terrain : caisses, TPE, tablettes, stock magasin.", "FINANCE & SUPPORT": "Équipes Finance, Offre, Web & RH. Incidents liés aux outils de gestion, au site web et aux flux administratifs." } # Affichage de la définition definition = DESCRIPTIONS_POLES.get(categorie, "Pôle nécessitant une investigation complémentaire.") st.info(f"💡 **À propos de {categorie} :** {definition}") st.info("💡 **Note :** L'IA route automatiquement le ticket vers le bon pôle de résolution, supprimant la nécessité d'un tri manuel par le support N1.") st.sidebar.caption("🟢 Container Status: Healthy (Port 7860)") # --------------------------------------------------------- # ZONE DE TEST (Pour la soutenance) # --------------------------------------------------------- st.markdown("---") with st.expander("🧪 Tickets de Démo (Copier-Coller pour le jury)"): st.markdown(""" **🚨 1. MONITORING** * **Objet :** `Opcon_Criticité1` * **Description :** `ID=95102 Event Trigger=Job Failed | Schedule Date=lundi 12 mai 2026 | Schedule Name=C1-SC-COM-SFSTraitementJournee_C1-SS-COM-StocksJournee[C1-SS-COM-StocksJournee] | Job Name=C1-FIL-Archiver-IntegrationStocks` **🏪 2. COMMERCE & MAGASINS** * **Objet :** `Alerte Processus Octipas Prod` * **Description :** `Alerte Processus Octipas Prod Alarme : Alarme Octipas Seuil de déclenchement des actions : 1. Le TPE du magasin de Lille ne fonctionne plus depuis ce matin.` **📊 3. DATA & BI** * **Objet :** `Data Winddle` * **Description :** `Bonjour, Nous venons de faire tourner les systèmes afin de voir les concordances des données entre les systèmes (Winddle-BO/Storeland). Les données de stock ne remontent pas correctement.` **⚙️ 4. RUN** * **Objet :** `[IMPORT ERROR - Grain de Malice] Problèmes détectés lors de l'import de données` * **Description :** `Bonjour, Nous avons détecté les problèmes suivant lors de l'import de données : Import des commandes Commande n° 33568324 reçue le 12/05/2026 avec l'erreur : error executing actions on line item.` **💶 5. FINANCE & SUPPORT** * **Objet :** `Création compte Centric` * **Description :** `Bonjour, merci de créer un compte Centric pour la nouvelle collaboratrice Marie Dupont au service Achats.` """)