Spaces:
Sleeping
Sleeping
| """ | |
| Avis'IA Resto : gestion des réponses aux avis clients pour restaurateurs | |
| Déploiement : Hugging Face Spaces, SDK Gradio 6.19.0 (fourni par HF) | |
| Prérequis : secret MISTRAL_API_KEY dans Settings > Variables and secrets | |
| Approche : IA responsable, frugale et souveraine. Human-in-the-Loop. | |
| """ | |
| import html as html_lib | |
| import io | |
| import os | |
| import random | |
| import tempfile | |
| import time | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| import gradio as gr | |
| import pandas as pd | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| plt.rcParams.update({ | |
| "figure.facecolor": "#FFFFFF", | |
| "axes.facecolor": "#FFFFFF", | |
| "savefig.facecolor": "#FFFFFF", | |
| "text.color": "#000000", | |
| "axes.labelcolor": "#000000", | |
| "xtick.color": "#000000", | |
| "ytick.color": "#000000", | |
| "axes.edgecolor": "#DDDDDD", | |
| "font.size": 11, | |
| }) | |
| # --------------------------------------------------------------- | |
| # 1. Client Mistral (souverain, frugal : mistral-small-latest) | |
| # --------------------------------------------------------------- | |
| # .strip() : indispensable, un secret collé avec un retour à la ligne ou un | |
| # espace en trop rend l'en-tête HTTP invalide (LocalProtocolError: Illegal | |
| # header value) et fait échouer 100% des appels. | |
| MISTRAL_API_KEY = os.environ.get("MISTRAL_API_KEY", "").strip() | |
| MODEL = "mistral-small-latest" | |
| client = None | |
| ERREUR_INIT_CLIENT = None | |
| if MISTRAL_API_KEY: | |
| try: | |
| from mistralai import Mistral | |
| client = Mistral(api_key=MISTRAL_API_KEY) | |
| print(f"[AvisIA] Clé Mistral détectée ({len(MISTRAL_API_KEY)} caractères), " | |
| f"client initialisé.", flush=True) | |
| except Exception as e: | |
| client = None | |
| ERREUR_INIT_CLIENT = f"{type(e).__name__}: {e}" | |
| print(f"[AvisIA][ERREUR INIT MISTRAL] {ERREUR_INIT_CLIENT}", flush=True) | |
| else: | |
| print("[AvisIA][ATTENTION] Aucun secret MISTRAL_API_KEY détecté : " | |
| "toutes les réponses passeront en mode secours (templates par note).", | |
| flush=True) | |
| def diagnostiquer_mistral(): | |
| """Test direct de la chaîne Mistral, étape par étape, avec l'erreur | |
| exacte à chaque niveau. Un seul appel API minimal (quelques tokens).""" | |
| lignes = ["### Diagnostic de la connexion Mistral\n"] | |
| # Étape 1 : le secret est-il visible par l'application ? | |
| if not MISTRAL_API_KEY: | |
| lignes.append( | |
| "❌ **Étape 1 : secret introuvable.** La variable d'environnement " | |
| "`MISTRAL_API_KEY` est vide ou absente. Le nom du secret dans " | |
| "Settings > Variables and secrets doit être EXACTEMENT " | |
| "`MISTRAL_API_KEY` (majuscules, underscores). Après ajout ou " | |
| "modification d'un secret, le Space doit être redémarré " | |
| "(bouton Restart en haut à droite du Space)." | |
| ) | |
| return "\n\n".join(lignes) | |
| masque = MISTRAL_API_KEY[:4] + "…" + MISTRAL_API_KEY[-4:] if len(MISTRAL_API_KEY) > 8 else "(clé très courte, suspecte)" | |
| lignes.append(f"✅ **Étape 1 : secret trouvé.** Clé lue : `{masque}` " | |
| f"({len(MISTRAL_API_KEY)} caractères).") | |
| # Étape 2 : le client a-t-il pu s'initialiser ? | |
| if client is None: | |
| lignes.append( | |
| f"❌ **Étape 2 : échec d'initialisation du client.** " | |
| f"Erreur : `{ERREUR_INIT_CLIENT}`. Cause probable : problème " | |
| f"avec la bibliothèque mistralai (vérifier `mistralai==1.8.2` " | |
| f"dans requirements.txt, puis Factory rebuild du Space)." | |
| ) | |
| return "\n\n".join(lignes) | |
| lignes.append("✅ **Étape 2 : client mistralai initialisé.**") | |
| # Étape 3 : un vrai appel API minimal aboutit-il ? | |
| try: | |
| resp = client.chat.complete( | |
| model=MODEL, | |
| messages=[{"role": "user", "content": "Réponds uniquement : OK"}], | |
| max_tokens=5, | |
| ) | |
| contenu = resp.choices[0].message.content.strip() | |
| lignes.append( | |
| f"✅ **Étape 3 : appel API réussi !** Le modèle `{MODEL}` a " | |
| f"répondu : « {contenu} ». La chaîne Mistral fonctionne de bout " | |
| f"en bout, les réponses aux avis passeront bien par l'IA." | |
| ) | |
| except Exception as e: | |
| type_err = type(e).__name__ | |
| detail = str(e)[:400] | |
| lignes.append( | |
| f"❌ **Étape 3 : l'appel API échoue.** Erreur : `{type_err}` : {detail}" | |
| ) | |
| detail_bas = detail.lower() | |
| if "illegal header" in detail_bas or "localprotocolerror" in type_err.lower(): | |
| lignes.append( | |
| "➡️ **Cause : caractère invisible dans la clé** (retour à la " | |
| "ligne ou espace collé avec, visible sous la forme `\\n` dans " | |
| "l'erreur). L'application nettoie désormais la clé " | |
| "automatiquement : si cette erreur apparaît encore, Restart " | |
| "le Space pour recharger le code, ou recoller la clé " | |
| "proprement dans le secret (sans sélectionner de ligne vide)." | |
| ) | |
| elif "401" in detail or "unauthorized" in detail_bas or "invalid" in detail_bas: | |
| lignes.append( | |
| "➡️ **Cause : clé refusée par Mistral (401).** La clé est " | |
| "invalide, révoquée ou mal copiée (espace ou retour à la " | |
| "ligne collé avec). Régénérer une clé sur console.mistral.ai " | |
| "(section API Keys), la recopier soigneusement dans le " | |
| "secret, puis Restart du Space." | |
| ) | |
| elif "429" in detail or "rate" in detail_bas or "capacity" in detail_bas: | |
| lignes.append( | |
| "➡️ **Cause : quota ou limite de débit (429).** Le crédit " | |
| "est épuisé ou trop de requêtes simultanées. Vérifier " | |
| "Billing et Limits sur console.mistral.ai. Important : sans " | |
| "moyen de paiement configuré sur la console Mistral, la " | |
| "clé fonctionne très peu ou pas du tout (free tier très " | |
| "limité)." | |
| ) | |
| elif "402" in detail or "payment" in detail_bas or "billing" in detail_bas: | |
| lignes.append( | |
| "➡️ **Cause : facturation (402).** Aucun crédit actif sur le " | |
| "compte Mistral. Activer un moyen de paiement ou des crédits " | |
| "sur console.mistral.ai, section Billing." | |
| ) | |
| elif "connect" in detail_bas or "timeout" in detail_bas or "name resolution" in detail_bas: | |
| lignes.append( | |
| "➡️ **Cause : réseau.** Le Space n'arrive pas à joindre " | |
| "api.mistral.ai. Généralement transitoire, réessayer dans " | |
| "quelques minutes." | |
| ) | |
| else: | |
| lignes.append( | |
| "➡️ Cause non reconnue automatiquement : copier le message " | |
| "d'erreur ci-dessus tel quel pour investiguer." | |
| ) | |
| return "\n\n".join(lignes) | |
| # --------------------------------------------------------------- | |
| # 2. Génération de réponse (IA personnalisée + secours templates) | |
| # --------------------------------------------------------------- | |
| SYSTEM_PROMPT = ( | |
| "Tu es le gérant d'un restaurant en Normandie. Tu réponds aux avis clients " | |
| "de façon professionnelle, chaleureuse et surtout précise : ta réponse " | |
| "doit refléter fidèlement le contenu réel de l'avis, pas seulement sa note.\n\n" | |
| "Règles impératives :\n" | |
| "- Base-toi sur le TEXTE de l'avis, pas seulement la note : un avis peut " | |
| "être nuancé (un point positif ET un point à améliorer dans le même avis). " | |
| "Traite chaque point mentionné séparément, ne les fonds pas en une phrase vague.\n" | |
| "- Note 4-5 : remercie en reprenant un détail concret et spécifique de " | |
| "l'avis, jamais une formule générique interchangeable d'un avis à l'autre.\n" | |
| "- Note 3, ou avis positif ET négatif mélangés : reconnais explicitement " | |
| "ce qui a plu ET ce qui a déçu, sans minimiser ni exagérer l'un ou " | |
| "l'autre. Indique une piste d'amélioration concrète liée au point faible " | |
| "cité. N'écris jamais une réponse uniformément positive pour un avis mitigé.\n" | |
| "- Note 1-2 : nomme précisément le problème évoqué dans l'avis (jamais " | |
| "une formule passe-partout comme 'vos remarques sont précieuses'). " | |
| "Calibre la gravité de tes excuses sur la gravité réelle du problème : " | |
| "un léger retard n'appelle pas la même intensité qu'un problème " | |
| "d'hygiène. Propose un geste concret et proportionné.\n" | |
| "- Varie systématiquement ta phrase d'ouverture d'un avis à l'autre : " | |
| "n'utilise jamais deux fois de suite la même formule (interdiction de " | |
| "toujours commencer par 'Merci' ou 'Nous sommes désolés'). Un message " | |
| "utilisateur t'indiquera à chaque fois une 'consigne de style' précise " | |
| "pour l'ouverture : applique-la scrupuleusement, elle change " | |
| "intentionnellement à chaque avis pour éviter les réponses formatées " | |
| "de façon identique.\n" | |
| "- Réutilise si possible un ou deux mots ou expressions employés " | |
| "textuellement par le client dans son avis (le nom d'un plat, un " | |
| "adjectif marquant, une expression précise) plutôt que de reformuler " | |
| "systématiquement avec tes propres mots : ça renforce l'impression " | |
| "d'une réponse vraiment lue et personnalisée, pas générée en série.\n" | |
| "- Réponse courte, 3 à 5 phrases maximum. Tutoiement interdit. Ne sois " | |
| "jamais défensif. Ne promets jamais de remboursement. Termine par la " | |
| "signature fournie." | |
| ) | |
| TEMPLATES = { | |
| 1: ("Nous sommes sincèrement désolés que votre expérience n'ait pas été " | |
| "à la hauteur. Vos remarques sont précieuses et nous allons en parler " | |
| "dès aujourd'hui avec l'équipe. Nous serions heureux de vous accueillir " | |
| "à nouveau pour vous faire changer d'avis."), | |
| 2: ("Merci d'avoir pris le temps de nous faire ce retour. Nous regrettons " | |
| "que votre visite ne vous ait pas pleinement satisfait et nous allons " | |
| "travailler sur les points que vous soulevez. Au plaisir de vous " | |
| "revoir prochainement."), | |
| 3: ("Merci pour votre retour honnête. Nous prenons note de vos remarques " | |
| "pour continuer à nous améliorer, et nous espérons vous convaincre " | |
| "pleinement lors d'une prochaine visite."), | |
| 4: ("Merci beaucoup pour ce retour très positif ! Nous sommes ravis que " | |
| "votre expérience vous ait plu. Toute l'équipe espère vous revoir " | |
| "très bientôt."), | |
| 5: ("Un grand merci pour ce superbe avis ! C'est un vrai plaisir de lire " | |
| "que tout était à la hauteur de vos attentes. À très bientôt !"), | |
| } | |
| # Consignes de style tirées au sort à chaque appel : on ne compte pas | |
| # uniquement sur la température pour varier les réponses (un modèle peut | |
| # rester formaté même à température élevée), on force une vraie diversité | |
| # structurelle en imposant un angle différent à chaque fois. | |
| STYLES_OUVERTURE = [ | |
| "Ouvre par un remerciement sincère qui nomme immédiatement le point " | |
| "précis apprécié ou signalé, sans formule introductive.", | |
| "Ouvre par une courte phrase qui reformule ce que le client a vécu, " | |
| "avant toute autre chose, comme pour montrer que l'avis a été vraiment lu.", | |
| "Ouvre directement sur le point concret mentionné dans l'avis (le plat, " | |
| "le service, l'attente...), le remerciement ou les excuses viennent après.", | |
| "Ouvre par une phrase brève et chaleureuse, presque orale, qui évite " | |
| "toute formule figée du type 'Merci pour votre avis' ou 'Nous sommes désolés'.", | |
| "Ouvre en interpellant directement ce qui a marqué dans l'avis, avec une " | |
| "réaction spontanée avant de structurer le reste de la réponse.", | |
| ] | |
| def generer_reponse(avis: str, note: int, restaurant: str, signature: str): | |
| """Retourne (réponse, message de mode). Ne plante jamais. | |
| En cas d'erreur de débit (429), réessaie jusqu'à 3 fois avec une attente | |
| progressive : indispensable sur le plan gratuit Mistral (~1 requête/s).""" | |
| avis = (avis or "").strip() | |
| if not avis: | |
| return "", "⚠️ Veuillez coller le texte de l'avis client avant de générer." | |
| note = int(note) | |
| restaurant = (restaurant or "notre restaurant").strip() | |
| signature = (signature or "La direction").strip() | |
| if client is not None: | |
| derniere_erreur = None | |
| for tentative in range(3): | |
| try: | |
| style_impose = random.choice(STYLES_OUVERTURE) | |
| user_msg = ( | |
| f"Restaurant : {restaurant}\n" | |
| f"Note laissée : {note}/5\n" | |
| f"Avis du client : \"{avis}\"\n" | |
| f"Signature à utiliser : {signature}\n" | |
| f"Consigne de style pour CETTE réponse précise : {style_impose}\n\n" | |
| "Rédige la réponse du restaurateur." | |
| ) | |
| resp = client.chat.complete( | |
| model=MODEL, | |
| messages=[ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_msg}, | |
| ], | |
| temperature=0.95, | |
| max_tokens=350, | |
| ) | |
| texte = resp.choices[0].message.content.strip() | |
| mode = ("✅ Réponse générée par IA (Mistral, mistral-small-latest). " | |
| "Relisez et modifiez avant publication : la décision finale " | |
| "vous appartient (Human-in-the-Loop).") | |
| return texte, mode | |
| except Exception as e: | |
| derniere_erreur = e | |
| message = str(e).lower() | |
| # Erreur de débit ou de capacité : on attend puis on réessaie | |
| if "429" in str(e) or "rate" in message or "capacity" in message: | |
| time.sleep(1.5 * (tentative + 1)) | |
| continue | |
| break # autre erreur : inutile de réessayer | |
| e = derniere_erreur | |
| print(f"[AvisIA][ERREUR MISTRAL] {type(e).__name__}: {str(e)[:300]}", flush=True) | |
| texte = TEMPLATES[note] + f"\n\n{signature}" | |
| mode = (f"🔶 Mode secours : l'API Mistral est indisponible " | |
| f"({type(e).__name__}). Réponse standard adaptée à la note, " | |
| "à personnaliser avant publication.") | |
| return texte, mode | |
| texte = TEMPLATES[note] + f"\n\n{signature}" | |
| mode = ("🔶 Mode secours : aucune clé API configurée (secret MISTRAL_API_KEY). " | |
| "Réponse standard adaptée à la note, à personnaliser avant publication.") | |
| return texte, mode | |
| # --------------------------------------------------------------- | |
| # 3. Analyse CSV multi-restaurants (images statiques matplotlib, | |
| # volontairement non-interactives : robustes, sans dépendance JS, | |
| # s'affichent quel que soit le navigateur ou les bloqueurs de script) | |
| # --------------------------------------------------------------- | |
| COLONNES_REQUISES = {"restaurant", "plateforme", "note", "avis", "date"} | |
| VERT = "#06C167" | |
| NOIR = "#000000" | |
| PALETTE = ["#06C167", "#000000", "#545454", "#8A8A8A", "#B8B8B8"] | |
| def _fig_to_image(fig): | |
| """Convertit une figure matplotlib en image PIL (PNG en mémoire).""" | |
| from PIL import Image | |
| buf = io.BytesIO() | |
| fig.savefig(buf, format="png", dpi=150, bbox_inches="tight", | |
| facecolor="#FFFFFF") | |
| plt.close(fig) | |
| buf.seek(0) | |
| return Image.open(buf) | |
| def _image_vide(message: str): | |
| fig, ax = plt.subplots(figsize=(6, 3.5)) | |
| ax.text(0.5, 0.5, message, ha="center", va="center", fontsize=13, color=NOIR) | |
| ax.axis("off") | |
| return _fig_to_image(fig) | |
| # Détection thématique par mots-clés (approche simple, frugale, sans appel | |
| # API supplémentaire) : reprend la grille thématique utilisée dans les | |
| # versions précédentes du projet. | |
| THEMES_MOTS = { | |
| "cuisine": ["cuisine", "plat", "cuisiné", "goût", "saveur", "assaisonnement", "cuisson", "cuit"], | |
| "service": ["service", "serveur", "serveuse", "personnel", "staff", "aimable", "désagréable"], | |
| "attente": ["attente", "attendu", "attendre", "lent", "minutes", "rapide", "interminable"], | |
| "prix": ["prix", "cher", "coût", "tarif", "addition", "€", "euros"], | |
| "ambiance": ["ambiance", "bruit", "bruyant", "calme", "cadre", "déco", "terrasse", "vue", "sombre", "salle"], | |
| "propreté": ["propre", "propreté", "hygiène", "sale", "cheveu"], | |
| "accueil": ["accueil", "accueillant", "patron", "sourire", "chaleureux"], | |
| "carte": ["carte", "menu", "choix", "plat du jour"], | |
| "produits": ["frais", "local", "locaux", "normand", "maison", "produits"], | |
| } | |
| def detecter_themes(avis_texte: str) -> list: | |
| """Retourne la liste des thèmes détectés dans un avis (mots-clés, | |
| insensible à la casse). Un avis peut évoquer plusieurs thèmes.""" | |
| texte = f" {str(avis_texte).lower()} " | |
| trouves = [theme for theme, mots in THEMES_MOTS.items() | |
| if any(mot in texte for mot in mots)] | |
| return trouves if trouves else ["non classé"] | |
| def charger_csv(fichier) -> pd.DataFrame: | |
| """Charge un CSV en détectant automatiquement le séparateur (, ou ;).""" | |
| with open(fichier.name, "rb") as f: | |
| brut = f.read().decode("utf-8-sig", errors="replace") | |
| premiere = brut.splitlines()[0] if brut.splitlines() else "" | |
| sep = ";" if premiere.count(";") > premiere.count(",") else "," | |
| df = pd.read_csv(io.StringIO(brut), sep=sep) | |
| df.columns = [c.strip().lower() for c in df.columns] | |
| return df | |
| # --------------------------------------------------------------- | |
| # 3bis. Traitement en lot : plusieurs avis d'un coup depuis un CSV | |
| # --------------------------------------------------------------- | |
| COLONNES_REQUISES_LOT = {"avis", "note"} | |
| LIMITE_LOT = 100 # plafond de sécurité pour la démonstration | |
| def generer_reponses_batch(fichier): | |
| """Charge un CSV d'avis (colonnes minimales : avis, note ; optionnelles : | |
| restaurant, signature) et génère une réponse pour chaque ligne. | |
| Retourne (message de statut, aperçu DataFrame, chemin du CSV à télécharger).""" | |
| if fichier is None: | |
| return ("⚠️ Chargez un fichier CSV pour lancer la génération en lot.", | |
| None, None) | |
| try: | |
| df = charger_csv(fichier) | |
| except Exception as e: | |
| return f"❌ Impossible de lire le fichier : {e}", None, None | |
| manquantes = COLONNES_REQUISES_LOT - set(df.columns) | |
| if manquantes: | |
| return (f"❌ Colonnes manquantes : {', '.join(sorted(manquantes))}. " | |
| f"Colonnes attendues au minimum : avis, note. " | |
| f"Optionnelles : id, restaurant, plateforme, signature."), None, None | |
| tronque = "" | |
| if len(df) > LIMITE_LOT: | |
| df = df.head(LIMITE_LOT).copy() | |
| tronque = f" (limité aux {LIMITE_LOT} premières lignes pour cette démonstration)" | |
| df["note"] = pd.to_numeric(df["note"], errors="coerce").fillna(3).astype(int).clip(1, 5) | |
| if "restaurant" not in df.columns: | |
| df["restaurant"] = "Notre restaurant" | |
| if "signature" not in df.columns: | |
| df["signature"] = "La direction" | |
| if "plateforme" not in df.columns: | |
| df["plateforme"] = "Non précisée" | |
| if "id" not in df.columns: | |
| df["id"] = [f"AVIS-{i+1:03d}" for i in range(len(df))] | |
| else: | |
| df["id"] = df["id"].astype(str) | |
| # Appels API en parallèle (I/O-bound) : accélère nettement le traitement | |
| # en lot par rapport à un envoi séquentiel avis par avis. | |
| n = len(df) | |
| reponses = [None] * n | |
| modes = [None] * n | |
| erreurs_rencontrees = [] | |
| def _traiter(i, avis, note, restaurant, signature): | |
| r, m = generer_reponse(str(avis), int(note), str(restaurant), str(signature)) | |
| if "✅" in m: | |
| return i, r, "IA", None | |
| # Extrait le type d'erreur technique du message (entre parenthèses) | |
| erreur = m.split("(")[-1].split(")")[0] if "(" in m else "inconnue" | |
| return i, r, "Secours", erreur | |
| # Appels API en parallèle mesuré : 3 simultanés maximum, un compromis | |
| # entre vitesse et respect des limites de débit Mistral (le plan gratuit | |
| # tolère mal les rafales, le retry avec attente absorbe le reste). | |
| with ThreadPoolExecutor(max_workers=min(3, max(1, n))) as executor: | |
| futures = [ | |
| executor.submit(_traiter, i, row["avis"], row["note"], | |
| row["restaurant"], row["signature"]) | |
| for i, (_, row) in enumerate(df.iterrows()) | |
| ] | |
| for future in as_completed(futures): | |
| i, r, mode, erreur = future.result() | |
| reponses[i] = r | |
| modes[i] = mode | |
| if erreur: | |
| erreurs_rencontrees.append(erreur) | |
| df["reponse_generee"] = reponses | |
| df["mode"] = modes | |
| chemin = os.path.join(tempfile.gettempdir(), "avis_reponses_generees.csv") | |
| df.to_csv(chemin, index=False, encoding="utf-8-sig") | |
| nb_ia = modes.count("IA") | |
| nb_secours = len(df) - nb_ia | |
| statut = (f"✅ {len(df)} réponses générées{tronque}. " | |
| f"{nb_ia} via l'IA Mistral, {nb_secours} en mode secours. " | |
| f"Relisez chaque réponse avant publication.") | |
| # Si le mode secours domine largement, on remonte la cause probable : | |
| # ça évite de deviner (clé absente ? quota ? limite de débit ?). | |
| if nb_secours > 0 and nb_ia == 0 and client is not None: | |
| from collections import Counter | |
| cause_principale = Counter(erreurs_rencontrees).most_common(1)[0][0] | |
| statut += ( | |
| f"\n\n⚠️ **Aucune réponse n'a pu venir de Mistral, cause technique : " | |
| f"`{cause_principale}`.** Causes les plus fréquentes : quota " | |
| f"épuisé ou limite de débit atteinte (trop d'appels simultanés), " | |
| f"clé API invalide ou expirée. Vérifiez votre quota sur " | |
| f"console.mistral.ai. Si le problème vient d'une limite de débit, " | |
| f"réessayez avec un fichier plus petit ou patientez quelques minutes." | |
| ) | |
| elif nb_secours > 0 and client is not None and erreurs_rencontrees: | |
| from collections import Counter | |
| cause_principale = Counter(erreurs_rencontrees).most_common(1)[0][0] | |
| statut += ( | |
| f"\n\n🔶 {nb_secours} réponse(s) sur {len(df)} sont passées en mode " | |
| f"secours (cause : `{cause_principale}`), probablement une limite " | |
| f"de débit ponctuelle sur les appels simultanés." | |
| ) | |
| apercu = df[["id", "restaurant", "plateforme", "avis", "note", "reponse_generee", "mode"]] | |
| return statut, apercu, chemin | |
| def _df_vers_tableau_html(df) -> str: | |
| """Rend le DataFrame en vrai tableau HTML/CSS, indépendant des classes | |
| internes de Gradio : forme garantie rectangulaire, sans coin arrondi.""" | |
| if df is None or len(df) == 0: | |
| return "<p style='color:#000000;'>Aucun résultat à afficher pour le moment.</p>" | |
| lignes = [] | |
| for _, row in df.iterrows(): | |
| id_avis = html_lib.escape(str(row.get("id", ""))) | |
| restaurant = html_lib.escape(str(row.get("restaurant", ""))) | |
| plateforme = html_lib.escape(str(row.get("plateforme", ""))) | |
| avis = html_lib.escape(str(row["avis"])) | |
| note = html_lib.escape(str(row["note"])) | |
| reponse = html_lib.escape(str(row["reponse_generee"])).replace("\n", "<br>") | |
| mode = html_lib.escape(str(row["mode"])) | |
| lignes.append( | |
| f"<tr><td class='avisia-centre'>{id_avis}</td>" | |
| f"<td>{restaurant}</td><td>{plateforme}</td>" | |
| f"<td>{avis}</td><td class='avisia-centre'>{note}</td>" | |
| f"<td>{reponse}</td><td class='avisia-centre'>{mode}</td></tr>" | |
| ) | |
| corps = "\n".join(lignes) | |
| return f""" | |
| <div class="avisia-table-wrap"> | |
| <style> | |
| .avisia-table-wrap {{ width: 100%; overflow-x: auto; }} | |
| .avisia-table {{ | |
| width: 100%; border-collapse: collapse; background: #FFFFFF !important; | |
| font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #000000; | |
| border-radius: 0 !important; box-shadow: none !important; | |
| }} | |
| .avisia-table th, .avisia-table td {{ | |
| border: 1px solid #DDDDDD !important; padding: 10px 12px; text-align: left; | |
| vertical-align: top; border-radius: 0 !important; background: #FFFFFF; | |
| color: #000000; | |
| }} | |
| .avisia-table th {{ background: #F6F6F6 !important; font-weight: 600; }} | |
| .avisia-table tr:nth-child(even) td {{ background: #FAFAFA !important; }} | |
| .avisia-centre {{ text-align: center; white-space: nowrap; }} | |
| </style> | |
| <table class="avisia-table"> | |
| <thead><tr><th>ID</th><th>Restaurant</th><th>Plateforme</th><th>Avis</th> | |
| <th>Note</th><th>Réponse générée</th><th>Mode</th></tr></thead> | |
| <tbody> | |
| {corps} | |
| </tbody> | |
| </table> | |
| </div> | |
| """ | |
| def generer_reponses_batch_ui(fichier): | |
| """Enveloppe generer_reponses_batch pour l'interface : convertit | |
| l'aperçu en vrai tableau HTML au lieu du composant Dataframe.""" | |
| statut, apercu, chemin = generer_reponses_batch(fichier) | |
| tableau_html = _df_vers_tableau_html(apercu) | |
| return statut, tableau_html, chemin | |
| def analyser_csv(fichier): | |
| vide = _image_vide("Chargez un fichier CSV pour afficher ce graphique.") | |
| if fichier is None: | |
| return "⚠️ Aucun fichier chargé. Utilisez le CSV d'exemple fourni.", vide, vide, vide, vide, vide, vide, vide | |
| try: | |
| df = charger_csv(fichier) | |
| except Exception as e: | |
| return f"❌ Impossible de lire le fichier : {e}", vide, vide, vide, vide, vide, vide, vide | |
| manquantes = COLONNES_REQUISES - set(df.columns) | |
| if manquantes: | |
| return (f"❌ Colonnes manquantes : {', '.join(sorted(manquantes))}. " | |
| f"Colonnes attendues : {', '.join(sorted(COLONNES_REQUISES))}."), vide, vide, vide, vide, vide, vide, vide | |
| df["note"] = pd.to_numeric(df["note"], errors="coerce") | |
| df = df.dropna(subset=["note"]) | |
| df["date"] = pd.to_datetime(df["date"], errors="coerce") | |
| # Synthèse chiffrée | |
| nb = len(df) | |
| moyenne = df["note"].mean() | |
| part_negatifs = (df["note"] <= 2).mean() * 100 | |
| meilleur = df.groupby("restaurant")["note"].mean().idxmax() | |
| synthese = ( | |
| f"### Synthèse\n" | |
| f"**{nb} avis analysés** sur {df['restaurant'].nunique()} restaurants " | |
| f"et {df['plateforme'].nunique()} plateformes.\n\n" | |
| f"Note moyenne globale : **{moyenne:.2f}/5**. " | |
| f"Avis négatifs (1 à 2 étoiles) : **{part_negatifs:.0f}%**. " | |
| f"Établissement le mieux noté : **{meilleur}**." | |
| ) | |
| # G1 : note moyenne par restaurant | |
| par_resto = df.groupby("restaurant")["note"].mean().sort_values() | |
| fig1, ax1 = plt.subplots(figsize=(6.5, 4)) | |
| ax1.barh(par_resto.index, par_resto.values, color=VERT) | |
| for i, v in enumerate(par_resto.values): | |
| ax1.text(v + 0.05, i, f"{v:.2f}", va="center", fontsize=10) | |
| ax1.set_xlim(0, 5.5) | |
| ax1.set_title("Note moyenne par restaurant", fontsize=14, fontweight="bold") | |
| ax1.set_xlabel("Note moyenne") | |
| for spine in ("top", "right"): | |
| ax1.spines[spine].set_visible(False) | |
| fig1.tight_layout() | |
| # G2 : répartition des notes | |
| repart = df["note"].value_counts().sort_index() | |
| fig2, ax2 = plt.subplots(figsize=(6.5, 4)) | |
| ax2.bar(repart.index.astype(int), repart.values, color=NOIR) | |
| for x, v in zip(repart.index.astype(int), repart.values): | |
| ax2.text(x, v + max(repart.values) * 0.02, str(int(v)), | |
| ha="center", fontsize=10) | |
| ax2.set_xticks(range(1, 6)) | |
| ax2.set_title("Répartition des notes", fontsize=14, fontweight="bold") | |
| ax2.set_xlabel("Note (étoiles)") | |
| ax2.set_ylabel("Nombre d'avis") | |
| for spine in ("top", "right"): | |
| ax2.spines[spine].set_visible(False) | |
| fig2.tight_layout() | |
| # G3 : volume d'avis par plateforme | |
| plat = df["plateforme"].value_counts() | |
| fig3, ax3 = plt.subplots(figsize=(6.5, 4)) | |
| wedges, _, autotexts = ax3.pie( | |
| plat.values, labels=plat.index, autopct="%1.0f%%", | |
| colors=PALETTE[:len(plat)], pctdistance=0.8, | |
| wedgeprops=dict(width=0.45, edgecolor="#FFFFFF"), | |
| textprops=dict(color=NOIR, fontsize=10), | |
| ) | |
| ax3.set_title("Volume d'avis par plateforme", fontsize=14, fontweight="bold") | |
| fig3.tight_layout() | |
| # G4 : évolution mensuelle de la note moyenne | |
| if df["date"].notna().any(): | |
| mensuel = (df.dropna(subset=["date"]) | |
| .assign(mois=lambda d: d["date"].dt.to_period("M").dt.to_timestamp()) | |
| .groupby(["mois", "restaurant"])["note"].mean() | |
| .unstack("restaurant")) | |
| fig4, ax4 = plt.subplots(figsize=(7, 4)) | |
| for i, resto in enumerate(mensuel.columns): | |
| ax4.plot(mensuel.index, mensuel[resto], marker="o", | |
| label=resto, color=PALETTE[i % len(PALETTE)]) | |
| ax4.set_ylim(0, 5.5) | |
| ax4.set_title("Évolution mensuelle de la note moyenne", fontsize=14, fontweight="bold") | |
| ax4.legend(fontsize=8, loc="upper left", bbox_to_anchor=(1.01, 1), frameon=False) | |
| for spine in ("top", "right"): | |
| ax4.spines[spine].set_visible(False) | |
| fig4.autofmt_xdate() | |
| fig4.tight_layout() | |
| image4 = _fig_to_image(fig4) | |
| else: | |
| image4 = _image_vide("Aucune date exploitable dans le fichier.") | |
| # G5 : thèmes évoqués par les clients (fréquence + note moyenne associée) | |
| df_themes = df.copy() | |
| df_themes["themes"] = df_themes["avis"].apply(detecter_themes) | |
| themes_exp = df_themes.explode("themes") | |
| themes_exp = themes_exp[themes_exp["themes"] != "non classé"] | |
| if len(themes_exp) > 0: | |
| stats_themes = (themes_exp.groupby("themes") | |
| .agg(mentions=("note", "size"), note_moy=("note", "mean")) | |
| .sort_values("mentions")) | |
| fig5, ax5 = plt.subplots(figsize=(6.5, 4)) | |
| couleurs = ["#C0392B" if n < 3 else "#B85C1F" if n < 4 else VERT | |
| for n in stats_themes["note_moy"]] | |
| ax5.barh(stats_themes.index, stats_themes["mentions"], color=couleurs) | |
| for i, (mentions, note_moy) in enumerate( | |
| zip(stats_themes["mentions"], stats_themes["note_moy"])): | |
| ax5.text(mentions + 0.1, i, f"{int(mentions)} avis · note {note_moy:.1f}", | |
| va="center", fontsize=9) | |
| ax5.set_title("Thèmes évoqués par les clients", fontsize=14, fontweight="bold") | |
| ax5.set_xlabel("Nombre de mentions") | |
| ax5.set_xlim(0, stats_themes["mentions"].max() * 1.4) | |
| for spine in ("top", "right"): | |
| ax5.spines[spine].set_visible(False) | |
| fig5.tight_layout() | |
| image5 = _fig_to_image(fig5) | |
| # G6 : points faibles à traiter en priorité (fréquence × négativité) | |
| stats_themes["part_negatifs"] = ( | |
| themes_exp[themes_exp["note"] <= 2].groupby("themes").size() | |
| .reindex(stats_themes.index).fillna(0) / stats_themes["mentions"] | |
| ) | |
| stats_themes["score_priorite"] = stats_themes["mentions"] * stats_themes["part_negatifs"] | |
| priorites = stats_themes[stats_themes["score_priorite"] > 0].sort_values("score_priorite") | |
| if len(priorites) > 0: | |
| top_priorites = priorites.tail(5) | |
| fig6, ax6 = plt.subplots(figsize=(6.5, 4)) | |
| ax6.barh(top_priorites.index, top_priorites["score_priorite"], color="#C0392B") | |
| for i, (score, part_neg) in enumerate( | |
| zip(top_priorites["score_priorite"], top_priorites["part_negatifs"])): | |
| ax6.text(score + score * 0.03, i, f"{part_neg*100:.0f}% d'avis négatifs", | |
| va="center", fontsize=9) | |
| ax6.set_title("Points faibles à traiter en priorité", fontsize=14, fontweight="bold") | |
| ax6.set_xlabel("Score de priorité (fréquence × part négative)") | |
| ax6.set_xlim(0, top_priorites["score_priorite"].max() * 1.55) | |
| for spine in ("top", "right"): | |
| ax6.spines[spine].set_visible(False) | |
| fig6.tight_layout() | |
| image6 = _fig_to_image(fig6) | |
| else: | |
| image6 = _image_vide("Aucun point faible récurrent détecté : bravo !") | |
| # G7 : thèmes évoqués, ventilés par restaurant (top thèmes uniquement, | |
| # pour rester lisible même avec plusieurs établissements) | |
| top_themes = stats_themes.sort_values("mentions", ascending=False).head(6).index.tolist() | |
| themes_top = themes_exp[themes_exp["themes"].isin(top_themes)] | |
| par_theme_resto = (themes_top.groupby(["themes", "restaurant"]).size() | |
| .unstack("restaurant").fillna(0) | |
| .reindex(top_themes)) | |
| restos_cols = list(par_theme_resto.columns) | |
| n_restos = max(len(restos_cols), 1) | |
| largeur = 0.8 / n_restos | |
| fig7, ax7 = plt.subplots(figsize=(7.5, 4.5)) | |
| positions_base = list(range(len(par_theme_resto.index))) | |
| for i, resto in enumerate(restos_cols): | |
| decalage = (i - (n_restos - 1) / 2) * largeur | |
| positions = [p + decalage for p in positions_base] | |
| ax7.bar(positions, par_theme_resto[resto], width=largeur * 0.92, | |
| label=resto, color=PALETTE[i % len(PALETTE)]) | |
| ax7.set_xticks(positions_base) | |
| ax7.set_xticklabels(par_theme_resto.index, rotation=25, ha="right") | |
| ax7.set_title("Thèmes évoqués par restaurant", fontsize=14, fontweight="bold") | |
| ax7.set_ylabel("Nombre de mentions") | |
| ax7.legend(fontsize=8, loc="upper left", bbox_to_anchor=(1.01, 1), frameon=False) | |
| for spine in ("top", "right"): | |
| ax7.spines[spine].set_visible(False) | |
| fig7.tight_layout() | |
| image7 = _fig_to_image(fig7) | |
| else: | |
| image5 = _image_vide("Aucun thème identifiable dans les avis (mots-clés absents).") | |
| image6 = _image_vide("Aucun point faible récurrent détecté : bravo !") | |
| image7 = _image_vide("Aucun thème identifiable dans les avis (mots-clés absents).") | |
| return (synthese, _fig_to_image(fig1), _fig_to_image(fig2), _fig_to_image(fig3), | |
| image4, image5, image6, image7) | |
| # --------------------------------------------------------------- | |
| # 4. Auto-tests intégrés (robustesse + rubrique qualité + traitement en lot) | |
| # --------------------------------------------------------------- | |
| MOTS_INTERDITS = ["rembours", "gratuit", "offert", " tu ", " ton ", " ta "] | |
| def _controle_qualite(avis: str, reponse: str, | |
| verifier_personnalisation: bool = True) -> tuple[bool, str]: | |
| """Contrôle qualité de type rubrique : personnalisation, longueur, | |
| absence de promesses interdites, absence de tutoiement.""" | |
| reponse_l = f" {reponse.lower()} " | |
| longueur_ok = 30 <= len(reponse) <= 900 | |
| sans_interdits = not any(mot in reponse_l for mot in MOTS_INTERDITS) | |
| if not longueur_ok: | |
| return False, "longueur hors norme" | |
| if not sans_interdits: | |
| return False, "formulation à risque détectée (promesse ou tutoiement)" | |
| if verifier_personnalisation: | |
| avis_mots = {m.strip(".,!?\"'").lower() for m in avis.split() if len(m) > 4} | |
| personnalisee = any(m in reponse_l for m in avis_mots) if avis_mots else True | |
| if not personnalisee: | |
| return False, "aucun mot de l'avis repris (personnalisation faible)" | |
| return True, "ok" | |
| def lancer_tests(): | |
| resultats = [] | |
| # --- Robustesse de base --- | |
| r, m = generer_reponse("", 3, "Test", "Test") | |
| resultats.append(("Avis vide refusé proprement", "⚠️" in m)) | |
| r_neg, m_neg = generer_reponse("Service très lent, plat froid, très déçu.", | |
| 1, "Le Normand Caen", "Marc") | |
| resultats.append(("Avis négatif : une réponse est produite", len(r_neg) > 30)) | |
| resultats.append(("Avis négatif : mode affiché à l'utilisateur", | |
| ("✅" in m_neg) or ("🔶" in m_neg))) | |
| r_pos, _ = generer_reponse("Excellent accueil, tarte normande divine, service parfait !", | |
| 5, "Le Normand Caen", "Marc") | |
| resultats.append(("Avis positif : une réponse est produite", len(r_pos) > 30)) | |
| # --- Rubrique qualité (va au-delà du "ça ne plante pas") --- | |
| # La personnalisation (reprise d'un mot de l'avis) n'a de sens qu'en mode | |
| # IA : les templates de secours sont génériques par construction. | |
| mode_ia_neg = "✅" in m_neg | |
| ok_neg, detail_neg = _controle_qualite( | |
| "Service très lent, plat froid, très déçu.", r_neg, | |
| verifier_personnalisation=mode_ia_neg) | |
| libelle_neg = detail_neg if mode_ia_neg else f"{detail_neg} (mode secours, hors périmètre)" | |
| resultats.append((f"Qualité avis négatif : {libelle_neg}", ok_neg)) | |
| mode_ia_pos = client is not None | |
| ok_pos, detail_pos = _controle_qualite( | |
| "Excellent accueil, tarte normande divine, service parfait !", r_pos, | |
| verifier_personnalisation=mode_ia_pos) | |
| libelle_pos = detail_pos if mode_ia_pos else f"{detail_pos} (mode secours, hors périmètre)" | |
| resultats.append((f"Qualité avis positif : {libelle_pos}", ok_pos)) | |
| resultats.append(("Réponse négative ≠ réponse positive (pas de copier-coller générique)", | |
| r_neg.strip() != r_pos.strip())) | |
| # --- Notes limites --- | |
| r1, _ = generer_reponse("Correct sans plus.", 1, "Test", "Test") | |
| r5b, _ = generer_reponse("Correct sans plus.", 5, "Test", "Test") | |
| resultats.append(("Note 1 et note 5 produisent des réponses différentes", | |
| r1.strip() != r5b.strip())) | |
| # --- Analyse CSV (onglet 2) --- | |
| dossier = os.path.dirname(os.path.abspath(__file__)) | |
| exemple = os.path.join(dossier, "avis_restaurant_exemple.csv") | |
| if os.path.exists(exemple): | |
| class F: | |
| name = exemple | |
| s, f1, f2, f3, f4, f5, f6, f7 = analyser_csv(F()) | |
| resultats.append(("CSV d'exemple : analyse sans erreur", s.startswith("###"))) | |
| figs_ok = all(hasattr(f, "size") and f.size[0] > 0 and f.size[1] > 0 | |
| for f in (f1, f2, f3, f4, f5, f6, f7)) | |
| resultats.append(("Les 7 graphiques contiennent des données", figs_ok)) | |
| else: | |
| resultats.append(("CSV d'exemple présent dans le Space", False)) | |
| # --- Traitement en lot (onglet 1, dépôt CSV) --- | |
| lot = os.path.join(dossier, "avis_a_repondre_exemple.csv") | |
| if os.path.exists(lot): | |
| # On teste sur les 3 premières lignes seulement : suffisant pour | |
| # valider le mécanisme, économe en quota API et en temps. | |
| df_mini = pd.read_csv(lot).head(3) | |
| chemin_mini = os.path.join(tempfile.gettempdir(), "_test_lot_mini.csv") | |
| df_mini.to_csv(chemin_mini, index=False, encoding="utf-8") | |
| class G: | |
| name = chemin_mini | |
| statut_lot, apercu, chemin = generer_reponses_batch(G()) | |
| resultats.append(("CSV en lot d'exemple : traitement sans erreur", | |
| statut_lot.startswith("✅"))) | |
| resultats.append(("CSV en lot : une réponse générée par ligne", | |
| apercu is not None and len(apercu) > 0 | |
| and apercu["reponse_generee"].notna().all())) | |
| resultats.append(("CSV en lot : fichier de sortie créé", | |
| chemin is not None and os.path.exists(chemin))) | |
| tableau_html = _df_vers_tableau_html(apercu) | |
| resultats.append(("Tableau HTML : rendu sans coin arrondi (vrai tableau)", | |
| "<table" in tableau_html and "border-radius: 0" in tableau_html)) | |
| else: | |
| resultats.append(("CSV en lot d'exemple présent dans le Space", False)) | |
| # --- Colonnes manquantes détectées proprement (lot) --- | |
| with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, | |
| newline="", encoding="utf-8") as tmp: | |
| tmp.write("colonne_inconnue\nvaleur\n") | |
| chemin_ko = tmp.name | |
| class H: | |
| name = chemin_ko | |
| statut_ko, _, _ = generer_reponses_batch(H()) | |
| resultats.append(("CSV en lot invalide : message d'erreur explicite", | |
| statut_ko.startswith("❌"))) | |
| os.remove(chemin_ko) | |
| ok = sum(1 for _, v in resultats if v) | |
| lignes = "\n".join(f"| {'✅' if v else '❌'} | {nom} |" for nom, v in resultats) | |
| statut_api = "✅ configurée" if client is not None else "🔶 absente (mode secours actif)" | |
| return ( | |
| f"### Résultats : {ok}/{len(resultats)} tests passés\n\n" | |
| f"Clé API Mistral : {statut_api}\n\n" | |
| f"| Statut | Test |\n|---|---|\n{lignes}" | |
| ) | |
| # --------------------------------------------------------------- | |
| # 5. Mode clair forcé (correctif fond noir, Gradio 6 / HF Spaces) | |
| # --------------------------------------------------------------- | |
| # Trois verrous complémentaires : | |
| # a) redirection ?__theme=light (Gradio lit ce paramètre au chargement) | |
| # b) interception de prefers-color-scheme (le navigateur ne peut plus | |
| # imposer son mode sombre) | |
| # c) CSS écrasant les variables du mode sombre au cas où | |
| HEAD_FORCE_LIGHT = """ | |
| <script> | |
| (function () { | |
| try { | |
| var u = new URL(window.location.href); | |
| if (u.searchParams.get('__theme') !== 'light') { | |
| u.searchParams.set('__theme', 'light'); | |
| window.location.replace(u.toString()); | |
| } | |
| } catch (e) {} | |
| try { | |
| var mm = window.matchMedia; | |
| window.matchMedia = function (q) { | |
| if (typeof q === 'string' && q.indexOf('prefers-color-scheme') !== -1) { | |
| return { | |
| matches: false, media: q, | |
| addListener: function () {}, removeListener: function () {}, | |
| addEventListener: function () {}, removeEventListener: function () {}, | |
| onchange: null, dispatchEvent: function () { return false; } | |
| }; | |
| } | |
| return mm.call(window, q); | |
| }; | |
| } catch (e) {} | |
| })(); | |
| </script> | |
| """ | |
| CSS_PRO = """ | |
| /* ==== Mode clair forcé : écrase toutes les variables du mode sombre ==== */ | |
| html, body, .gradio-container, .dark, body.dark { | |
| background: #FFFFFF !important; | |
| color: #000000 !important; | |
| } | |
| .dark { | |
| --body-background-fill: #FFFFFF !important; | |
| --background-fill-primary: #FFFFFF !important; | |
| --background-fill-secondary: #F6F6F6 !important; | |
| --block-background-fill: #FFFFFF !important; | |
| --body-text-color: #000000 !important; | |
| --block-label-text-color: #000000 !important; | |
| --block-title-text-color: #000000 !important; | |
| --input-background-fill: #F6F6F6 !important; | |
| --table-even-background-fill: #FFFFFF !important; | |
| --table-odd-background-fill: #F6F6F6 !important; | |
| --border-color-primary: #EEEEEE !important; | |
| } | |
| /* ==== Design flat professionnel ==== */ | |
| .gradio-container { max-width: 1100px !important; margin: 0 auto !important; } | |
| * { border-radius: 0 !important; } | |
| button { border-radius: 999px !important; } | |
| h1, h2, h3, label, p, span, .prose { color: #000000 !important; } | |
| .secondary-text, .gr-info { color: #545454 !important; } | |
| input, textarea, select { | |
| background: #F6F6F6 !important; | |
| color: #000000 !important; | |
| border: none !important; | |
| } | |
| button.primary, .primary { | |
| background: #06C167 !important; | |
| color: #000000 !important; | |
| font-weight: 600 !important; | |
| border: none !important; | |
| } | |
| button.primary:hover, .primary:hover { background: #05A857 !important; } | |
| /* ==== Tableau des avis : VRAI tableau, grille complète, zéro décoration ==== */ | |
| table, th, td, tr, | |
| .table-wrap, .table-wrap *, .dataframe, .dataframe *, | |
| [class*="dataframe"], [class*="table"], [class*="table"] * { | |
| border-radius: 0 !important; | |
| box-shadow: none !important; | |
| background-image: none !important; | |
| clip-path: none !important; | |
| -webkit-clip-path: none !important; | |
| mask: none !important; | |
| overflow: visible !important; | |
| } | |
| table { | |
| border-collapse: collapse !important; | |
| width: 100% !important; | |
| background: #FFFFFF !important; | |
| } | |
| table, th, td { | |
| border: 1px solid #DDDDDD !important; | |
| background: #FFFFFF !important; | |
| color: #000000 !important; | |
| } | |
| th { font-weight: 600 !important; background: #F6F6F6 !important; } | |
| .block, .form, .panel { | |
| background: #FFFFFF !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| } | |
| footer { display: none !important; } | |
| """ | |
| # --------------------------------------------------------------- | |
| # 6. Interface (4 onglets) | |
| # --------------------------------------------------------------- | |
| with gr.Blocks(title="Avis'IA Resto") as demo: | |
| gr.Markdown( | |
| "# 🍽️ Avis'IA Resto\n" | |
| "**Assistant de réponse aux avis clients pour restaurateurs.** " | |
| "L'IA propose, l'humain décide : chaque réponse est relue et validée " | |
| "avant publication (Human-in-the-Loop)." | |
| ) | |
| # ---------- Onglet 1 : répondre à un avis ---------- | |
| with gr.Tab("✉️ Répondre à un avis"): | |
| gr.Markdown( | |
| "### Déposez vos avis, obtenez vos réponses\n" | |
| "Une seule méthode, pour un avis isolé comme pour plusieurs à la fois : " | |
| "déposez un CSV. Pour un seul avis, un fichier d'une seule ligne suffit. " | |
| "Colonnes attendues : `avis, note` (obligatoires), " | |
| "`id, restaurant, plateforme, signature` (optionnelles, valeurs " | |
| "par défaut sinon, un identifiant est généré automatiquement si " | |
| "absent). " | |
| "Séparateur virgule ou point-virgule, détecté automatiquement. " | |
| f"Traitement limité à {LIMITE_LOT} lignes pour cette démonstration. " | |
| "Aucune donnée n'est conservée après la session (application stateless). " | |
| "Ce fichier alimente aussi automatiquement les graphiques de l'onglet " | |
| "\"Analyse multi-restaurants\" s'il contient les colonnes nécessaires " | |
| "(date, plateforme) : pas besoin de le déposer une seconde fois." | |
| ) | |
| with gr.Row(): | |
| csv_lot_in = gr.File(label="Fichier CSV des avis à traiter", file_types=[".csv"]) | |
| with gr.Column(): | |
| btn_lot = gr.Button("Générer les réponses", variant="primary") | |
| lot_fichier = gr.File(label="Télécharger le CSV avec les réponses") | |
| lot_statut = gr.Markdown() | |
| # --- Tableau des résultats en pleine largeur, en bas de page --- | |
| # Rendu en HTML pur (et non gr.Dataframe) : forme garantie | |
| # rectangulaire, plus aucune dépendance au CSS interne de Gradio. | |
| gr.Markdown("#### Résultats") | |
| lot_apercu = gr.HTML(value=_df_vers_tableau_html(None)) | |
| btn_lot.click(generer_reponses_batch_ui, [csv_lot_in], | |
| [lot_statut, lot_apercu, lot_fichier]) | |
| # ---------- Onglet 2 : analyse CSV ---------- | |
| with gr.Tab("📊 Analyse multi-restaurants"): | |
| gr.Markdown( | |
| "Chargez un export CSV de vos avis, ou passez directement à cet " | |
| "onglet si vous avez déjà déposé un fichier complet sur l'onglet " | |
| "\"Répondre à un avis\" : il est repris automatiquement ici. " | |
| "Pour analyser un fichier différent, déposez-le ci-dessous, il " | |
| "remplacera l'analyse en cours. Colonnes attendues : " | |
| "`date, restaurant, plateforme, note, avis`. " | |
| "Séparateur virgule ou point-virgule, détecté automatiquement. " | |
| "Aucune donnée n'est conservée après la session (application stateless)." | |
| ) | |
| csv_in = gr.File(label="Fichier CSV des avis", file_types=[".csv"]) | |
| btn_csv = gr.Button("Analyser", variant="primary") | |
| synth_out = gr.Markdown() | |
| with gr.Row(): | |
| g1 = gr.Image(label="Note moyenne par restaurant", show_label=True, | |
| container=True, buttons=[]) | |
| g2 = gr.Image(label="Répartition des notes", show_label=True, | |
| container=True, buttons=[]) | |
| with gr.Row(): | |
| g3 = gr.Image(label="Volume par plateforme", show_label=True, | |
| container=True, buttons=[]) | |
| g4 = gr.Image(label="Évolution mensuelle", show_label=True, | |
| container=True, buttons=[]) | |
| with gr.Row(): | |
| g5 = gr.Image(label="Thèmes évoqués par les clients", show_label=True, | |
| container=True, buttons=[]) | |
| g6 = gr.Image(label="Points faibles à traiter en priorité", show_label=True, | |
| container=True, buttons=[]) | |
| with gr.Row(): | |
| g7 = gr.Image(label="Thèmes évoqués par restaurant", show_label=True, | |
| container=True, buttons=[]) | |
| btn_csv.click(analyser_csv, [csv_in], [synth_out, g1, g2, g3, g4, g5, g6, g7]) | |
| # --- Partage automatique : un CSV déposé sur l'onglet 1 remplit aussi | |
| # l'onglet 2 et lance l'analyse, sans le charger une seconde fois. | |
| # L'onglet 2 reste indépendant : on peut toujours y déposer un autre | |
| # fichier pour analyser des données différentes. | |
| def analyser_et_partager(fichier): | |
| valeur_partagee = fichier.name if fichier is not None else None | |
| return (valeur_partagee,) + analyser_csv(fichier) | |
| csv_lot_in.upload(analyser_et_partager, [csv_lot_in], | |
| [csv_in, synth_out, g1, g2, g3, g4, g5, g6, g7]) | |
| # ---------- Onglet 3 : tests et qualité ---------- | |
| with gr.Tab("✅ Tests & Qualité"): | |
| gr.Markdown( | |
| "### Diagnostic de la connexion Mistral\n" | |
| "Ce bouton effectue un vrai appel API minimal et affiche l'erreur " | |
| "exacte à chaque étape (secret, initialisation, appel). C'est le " | |
| "premier réflexe si les réponses passent en mode secours." | |
| ) | |
| btn_diag = gr.Button("🔌 Tester la connexion Mistral", variant="primary") | |
| diag_out = gr.Markdown() | |
| btn_diag.click(diagnostiquer_mistral, [], [diag_out]) | |
| gr.Markdown( | |
| "---\n### Démarche qualité\n" | |
| "L'application embarque ses propres tests, exécutables à tout moment. " | |
| "Ils vérifient la robustesse (avis vide, API indisponible), la " | |
| "génération de réponses et le chargement des données. " | |
| "En cas d'indisponibilité de l'API Mistral, l'application bascule " | |
| "automatiquement en mode secours sans jamais planter. " | |
| "**Patience : avec une clé API active, les tests font de vrais " | |
| "appels Mistral et prennent 20 à 40 secondes.** Le résultat " | |
| "s'affiche d'un bloc à la fin." | |
| ) | |
| btn_tests = gr.Button("Lancer les auto-tests", variant="primary") | |
| tests_out = gr.Markdown() | |
| btn_tests.click(lancer_tests, [], [tests_out]) | |
| # ---------- Onglet 4 : méthode et limites ---------- | |
| with gr.Tab("📖 Méthode & Limites"): | |
| gr.Markdown( | |
| "### Une IA responsable, frugale et souveraine\n\n" | |
| "**Souveraineté.** Chaîne technologique européenne : Mistral AI " | |
| "(France) pour la génération, Hugging Face pour l'hébergement.\n\n" | |
| "**Frugalité.** Modèle léger `mistral-small-latest`, suffisant pour " | |
| "la tâche, coût et empreinte réduits. Pas de GPU nécessaire.\n\n" | |
| "**Human-in-the-Loop.** L'IA ne publie jamais rien. Chaque réponse " | |
| "est une proposition, relue, modifiable et validée par le restaurateur.\n\n" | |
| "**RGPD.** Application stateless : aucun avis, aucune donnée client " | |
| "n'est stocké. La clé API est un secret du Space, jamais exposée.\n\n" | |
| "**Accessibilité.** Thème clair à fort contraste (noir sur blanc, " | |
| "ratio 21:1), conforme aux exigences RGAA de contraste.\n\n" | |
| "### Former un non-averti à réunir ses avis dans un seul CSV\n\n" | |
| "Le restaurateur n'a besoin d'aucune compétence technique pour " | |
| "utiliser cette application : la seule étape manuelle est de " | |
| "réunir ses avis clients dans un seul fichier. La méthode la " | |
| "plus simple, sans export ni connecteur technique, consiste à " | |
| "ouvrir un tableur gratuit (Google Sheets ou Excel) avec six " | |
| "colonnes fixes : id, date, restaurant, plateforme, note, avis. " | |
| "Pour chaque avis reçu sur Google, TripAdvisor, TheFork ou les " | |
| "réseaux sociaux, il suffit de copier le texte de l'avis dans " | |
| "la colonne avis, la note en chiffre dans la colonne note, et " | |
| "le nom de la plateforme d'origine dans la colonne plateforme ; " | |
| "la colonne id peut rester vide, l'application en génère un " | |
| "automatiquement. Une feuille modèle prête à remplir, partagée " | |
| "une fois pour toutes, suffit : quelques minutes par semaine " | |
| "pour copier-coller les nouveaux avis, puis un export au format " | |
| "CSV (menu Fichier, Télécharger, Valeurs séparées par une " | |
| "virgule) avant de le déposer dans l'application. Cette " | |
| "approche volontairement simple, sans automatisation, " | |
| "correspond à la charge réelle d'un petit restaurant " | |
| "indépendant et s'enseigne en quelques minutes lors d'une " | |
| "prise en main.\n\n" | |
| "### Limites connues\n\n" | |
| "L'IA peut produire des formulations maladroites sur des avis très " | |
| "ambigus, d'où la relecture humaine systématique. Le mode secours " | |
| "produit des réponses génériques qui doivent être personnalisées. " | |
| "L'analyse CSV suppose un export propre avec les cinq colonnes attendues." | |
| ) | |
| # --------------------------------------------------------------- | |
| # 7. Lancement (Gradio 6 : theme, css et head se passent ICI) | |
| # --------------------------------------------------------------- | |
| if __name__ == "__main__": | |
| demo.launch( | |
| theme=gr.themes.Base( | |
| primary_hue=gr.themes.colors.green, | |
| neutral_hue=gr.themes.colors.gray, | |
| ), | |
| css=CSS_PRO, | |
| head=HEAD_FORCE_LIGHT, | |
| ) | |