Spaces:
Running
Running
| 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 --- | |
| 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(""" | |
| <style> | |
| /* Suppression des couleurs forcées pour laisser Streamlit gérer nativement le mode clair/sombre */ | |
| /* Bouton Premium avec effet au survol (adapté aux deux modes) */ | |
| .stButton>button { | |
| background: linear-gradient(45deg, #E29792, #d17a74) !important; | |
| color: white !important; /* Le texte du bouton peut rester blanc sur ce fond coloré */ | |
| border: none; | |
| border-radius: 12px; | |
| padding: 0.6rem 2rem; | |
| font-weight: 600; | |
| transition: all 0.3s ease; | |
| box-shadow: 0 4px 15px rgba(226, 151, 146, 0.3); | |
| width: 100%; | |
| } | |
| .stButton>button:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 6px 20px rgba(226, 151, 146, 0.4); | |
| } | |
| /* Cartes Métriques style Glassmorphism (adaptable) */ | |
| .stMetric { | |
| background: var(--secondary-background-color) !important; | |
| border: 1px solid var(--faded-text-color); | |
| padding: 20px; | |
| border-radius: 15px; | |
| border-left: 5px solid #E29792 !important; | |
| } | |
| /* Inputs arrondis s'adaptant au thème */ | |
| div[data-baseweb="input"], div[data-baseweb="textarea"] { | |
| border-radius: 10px !important; | |
| } | |
| </style> | |
| """, 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.` | |
| """) |