""" 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 "

Aucun résultat à afficher pour le moment.

" 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", "
") mode = html_lib.escape(str(row["mode"])) lignes.append( f"{id_avis}" f"{restaurant}{plateforme}" f"{avis}{note}" f"{reponse}{mode}" ) corps = "\n".join(lignes) return f"""
{corps}
IDRestaurantPlateformeAvis NoteRéponse généréeMode
""" 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)", " (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) {} })(); """ 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, )