GDM-Aide-RUN-V2 / app.py
Elkristobal59's picture
feat: optimize model loading, clean imports, run V4 training and fix tests
187f074
Raw
History Blame Contribute Delete
21.2 kB
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("""
<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.`
""")