DEMOAVISIA / app.py
Oxyb's picture
Upload app.py
31a5525 verified
Raw
History Blame Contribute Delete
54.3 kB
"""
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,
)