Spaces:
Sleeping
Sleeping
| """ | |
| Avis'IA Resto - application Gradio pour Hugging Face Spaces. | |
| Ce fichier contient : | |
| - une interface en 4 onglets : Repondre, Comparer, Tests, Methode ; | |
| - une analyse CSV reproductible avec pandas ; | |
| - 5 graphiques Plotly accessibles ; | |
| - une generation de reponse via Mistral AI si MISTRAL_API_KEY est configuree ; | |
| - un mode de secours local et deterministe pour que l'app reste testable sans cle API. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import re | |
| import sys | |
| import traceback | |
| import unicodedata | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Iterable, Optional | |
| import gradio as gr | |
| import pandas as pd | |
| import plotly.express as px | |
| import plotly.graph_objects as go | |
| try: # Mistral est optionnel pour les tests locaux. | |
| from mistralai.client import Mistral # SDK v2.x actuel | |
| except Exception: # pragma: no cover - depend de l'environnement de deploiement. | |
| try: | |
| from mistralai import Mistral # Compatibilite SDK v1.x | |
| except Exception: | |
| Mistral = None # type: ignore | |
| APP_DIR = Path(__file__).resolve().parent | |
| DEFAULT_CSV_PATH = APP_DIR / "avis_restaurant_exemple.csv" | |
| REQUIRED_COLUMNS = {"date", "restaurant", "note", "avis"} | |
| OPTIONAL_COLUMNS = {"plateforme"} | |
| MISTRAL_MODEL = os.getenv("MISTRAL_MODEL", "mistral-small-latest") | |
| MAX_AI_WORDS = 120 | |
| COLUMN_ALIASES = { | |
| "date avis": "date", | |
| "date_avis": "date", | |
| "created_at": "date", | |
| "etablissement": "restaurant", | |
| "resto": "restaurant", | |
| "site": "restaurant", | |
| "lieu": "restaurant", | |
| "rating": "note", | |
| "stars": "note", | |
| "etoiles": "note", | |
| "score": "note", | |
| "commentaire": "avis", | |
| "commentaires": "avis", | |
| "review": "avis", | |
| "reviews": "avis", | |
| "texte": "avis", | |
| "text": "avis", | |
| "source": "plateforme", | |
| "platform": "plateforme", | |
| } | |
| THEME_KEYWORDS = { | |
| "Service": [ | |
| "service", | |
| "serveur", | |
| "serveuse", | |
| "accueil", | |
| "aimable", | |
| "conseille", | |
| "patron", | |
| "chef", | |
| "pain", | |
| "carte", | |
| ], | |
| "Cuisine": [ | |
| "cuisine", | |
| "plat", | |
| "poisson", | |
| "sole", | |
| "camembert", | |
| "moule", | |
| "dessert", | |
| "tarte", | |
| "fruits de mer", | |
| "produits", | |
| "menu", | |
| "entree", | |
| "soupe", | |
| "froid", | |
| ], | |
| "Prix": [ | |
| "cher", | |
| "prix", | |
| "addition", | |
| "qualite-prix", | |
| "touriste", | |
| "quantite", | |
| "rapport", | |
| "17", | |
| "28", | |
| ], | |
| "Cadre": [ | |
| "cadre", | |
| "vue", | |
| "mer", | |
| "bruyant", | |
| "calme", | |
| "cathedrale", | |
| "quartier", | |
| "terrasse", | |
| ], | |
| "Attente": [ | |
| "attendu", | |
| "attente", | |
| "lent", | |
| "rapide", | |
| "efficace", | |
| "40 minutes", | |
| "semaine", | |
| ], | |
| "Hygiene": [ | |
| "cheveu", | |
| "sale", | |
| "proprete", | |
| "hygiene", | |
| "mouche", | |
| "moule", | |
| ], | |
| "Horaires": [ | |
| "ferme", | |
| "mardi", | |
| "horaires", | |
| "google", | |
| "prevenir", | |
| ], | |
| "Famille": [ | |
| "famille", | |
| "enfant", | |
| "nuggets", | |
| "dimanche", | |
| "parents", | |
| ], | |
| } | |
| EXAMPLE_REVIEWS = [ | |
| [ | |
| "Excellent repas en famille. La sole normande etait parfaite et le service tres attentionne. Cadre chaleureux.", | |
| 5, | |
| "Le Normand - Caen", | |
| "Google", | |
| ], | |
| [ | |
| "Service lent, plat froid et addition trop elevee. Tres decu par cette experience.", | |
| 1, | |
| "Le Normand - Cabourg", | |
| "TripAdvisor", | |
| ], | |
| [ | |
| "Correct pour un dejeuner rapide. La carte manque un peu de renouvellement.", | |
| 3, | |
| "Le Normand - Bayeux", | |
| "Google", | |
| ], | |
| ] | |
| CUSTOM_CSS = """ | |
| :root { font-size: 16px; } | |
| .gradio-container { max-width: 1180px !important; } | |
| body, .gradio-container, textarea, input, button { font-size: 16px !important; } | |
| .main-title { padding: 0.8rem 1rem; border-radius: 0.75rem; border: 1px solid #d7d7d7; } | |
| .helper-box { padding: 0.8rem 1rem; border-left: 4px solid #2f5d8c; background: #f7f9fc; } | |
| .small-note { font-size: 0.95rem; } | |
| """ | |
| class CheckResult: | |
| name: str | |
| ok: bool | |
| detail: str | |
| def status(self) -> str: | |
| return "OK" if self.ok else "ECHEC" | |
| def strip_accents(value: object) -> str: | |
| """Normalise un texte pour une recherche de mots-cles robuste.""" | |
| text = "" if value is None else str(value) | |
| normalized = unicodedata.normalize("NFKD", text) | |
| return normalized.encode("ascii", "ignore").decode("ascii").lower() | |
| def mask_personal_data(text: str) -> str: | |
| """Masque les donnees personnelles simples avant tout envoi potentiel a l'IA.""" | |
| if not text: | |
| return "" | |
| masked = re.sub(r"\b[\w.+-]+@[\w.-]+\.[a-zA-Z]{2,}\b", "[email masque]", text) | |
| masked = re.sub(r"(?:(?:\+33|0)[1-9](?:[\s.-]?\d{2}){4})", "[telephone masque]", masked) | |
| return masked | |
| def classify_sentiment(note: float | int | str) -> str: | |
| """Classe le sentiment a partir d'une note sur 5.""" | |
| try: | |
| value = float(note) | |
| except Exception: | |
| return "Non classe" | |
| if value >= 4: | |
| return "Positif" | |
| if value <= 2: | |
| return "Negatif" | |
| return "Mitige" | |
| def detect_themes(review_text: str) -> list[str]: | |
| """Detecte des themes metier simples et auditables a partir du texte d'avis.""" | |
| normalized = strip_accents(review_text) | |
| themes = [] | |
| for theme, keywords in THEME_KEYWORDS.items(): | |
| if any(keyword in normalized for keyword in keywords): | |
| themes.append(theme) | |
| return themes or ["General"] | |
| def standardize_columns(df: pd.DataFrame) -> pd.DataFrame: | |
| """Harmonise les noms de colonnes attendus par l'application.""" | |
| copy = df.copy() | |
| rename_map: dict[str, str] = {} | |
| for column in copy.columns: | |
| key = strip_accents(column).strip().replace("-", "_") | |
| key = re.sub(r"\s+", " ", key) | |
| normalized_key = key.replace(" ", "_") if key not in COLUMN_ALIASES else key | |
| target = COLUMN_ALIASES.get(key) or COLUMN_ALIASES.get(normalized_key) or key | |
| rename_map[column] = target | |
| copy = copy.rename(columns=rename_map) | |
| return copy | |
| def coerce_file_path(file_input: object | None) -> Path: | |
| """Convertit l'entree Gradio en chemin de fichier local.""" | |
| if file_input is None: | |
| return DEFAULT_CSV_PATH | |
| if isinstance(file_input, Path): | |
| return file_input | |
| if isinstance(file_input, str): | |
| return Path(file_input) | |
| name = getattr(file_input, "name", None) | |
| if name: | |
| return Path(name) | |
| path = getattr(file_input, "path", None) | |
| if path: | |
| return Path(path) | |
| raise ValueError("Fichier CSV non reconnu par l'application.") | |
| def load_reviews(file_input: object | None = None) -> pd.DataFrame: | |
| """Charge un CSV d'avis et renvoie un DataFrame brut standardise.""" | |
| csv_path = coerce_file_path(file_input) | |
| if not csv_path.exists(): | |
| raise FileNotFoundError(f"Fichier introuvable: {csv_path}") | |
| df = pd.read_csv(csv_path) | |
| return standardize_columns(df) | |
| def prepare_reviews(df: pd.DataFrame) -> pd.DataFrame: | |
| """Valide et enrichit les avis avec sentiment, themes et periode.""" | |
| if df is None or df.empty: | |
| raise ValueError("Le fichier ne contient aucun avis.") | |
| prepared = standardize_columns(df) | |
| missing = sorted(REQUIRED_COLUMNS - set(prepared.columns)) | |
| if missing: | |
| expected = ", ".join(sorted(REQUIRED_COLUMNS | OPTIONAL_COLUMNS)) | |
| raise ValueError(f"Colonnes manquantes: {', '.join(missing)}. Colonnes attendues: {expected}.") | |
| prepared = prepared.copy() | |
| prepared["restaurant"] = prepared["restaurant"].astype(str).str.strip() | |
| prepared["avis"] = prepared["avis"].astype(str).str.strip() | |
| prepared["note"] = pd.to_numeric(prepared["note"], errors="coerce") | |
| prepared["date"] = pd.to_datetime(prepared["date"], errors="coerce") | |
| if "plateforme" not in prepared.columns: | |
| prepared["plateforme"] = "Non precisee" | |
| prepared["plateforme"] = prepared["plateforme"].fillna("Non precisee").astype(str).str.strip() | |
| prepared = prepared.dropna(subset=["note"]) | |
| prepared = prepared[(prepared["note"] >= 1) & (prepared["note"] <= 5)] | |
| prepared = prepared[prepared["restaurant"].str.len() > 0] | |
| prepared = prepared[prepared["avis"].str.len() > 0] | |
| if prepared.empty: | |
| raise ValueError("Aucun avis valide apres controle des notes, restaurants et textes.") | |
| prepared["sentiment"] = prepared["note"].apply(classify_sentiment) | |
| prepared["themes"] = prepared["avis"].apply(detect_themes) | |
| prepared["theme_principal"] = prepared["themes"].apply(lambda values: values[0] if values else "General") | |
| prepared["mois"] = prepared["date"].dt.to_period("M").astype(str) | |
| prepared.loc[prepared["date"].isna(), "mois"] = "Date inconnue" | |
| prepared["avis_masque"] = prepared["avis"].apply(mask_personal_data) | |
| return prepared.reset_index(drop=True) | |
| def load_and_prepare(file_input: object | None = None) -> pd.DataFrame: | |
| return prepare_reviews(load_reviews(file_input)) | |
| def get_default_restaurants() -> list[str]: | |
| try: | |
| df = load_and_prepare(DEFAULT_CSV_PATH) | |
| return sorted(df["restaurant"].dropna().unique().tolist()) | |
| except Exception: | |
| return ["Le Normand - Caen", "Le Normand - Bayeux", "Le Normand - Cabourg"] | |
| def format_theme_list(themes: Iterable[str]) -> str: | |
| values = [theme for theme in themes if theme and theme != "General"] | |
| if not values: | |
| return "l'experience client" | |
| if len(values) == 1: | |
| return values[0].lower() | |
| return ", ".join(theme.lower() for theme in values[:-1]) + " et " + values[-1].lower() | |
| def mistral_complete(messages: list[dict[str, str]], max_tokens: int = 350) -> Optional[str]: | |
| """Appelle Mistral si la cle API est presente, sinon renvoie None.""" | |
| api_key = os.getenv("MISTRAL_API_KEY") | |
| if not api_key or Mistral is None: | |
| return None | |
| try: | |
| client = Mistral(api_key=api_key) | |
| response = client.chat.complete( | |
| model=MISTRAL_MODEL, | |
| messages=messages, | |
| temperature=0.25, | |
| max_tokens=max_tokens, | |
| ) | |
| content = response.choices[0].message.content | |
| if isinstance(content, list): | |
| parts = [] | |
| for item in content: | |
| value = getattr(item, "text", None) or str(item) | |
| parts.append(value) | |
| content = "\n".join(parts) | |
| cleaned = str(content).strip() | |
| return cleaned or None | |
| except Exception as exc: # pragma: no cover - depend du reseau/API. | |
| print(f"[AvisIA] Appel Mistral indisponible, bascule locale: {exc}", file=sys.stderr) | |
| return None | |
| def fallback_review_response( | |
| review_text: str, | |
| note: float | int | str, | |
| restaurant: str = "notre restaurant", | |
| platform: str = "la plateforme", | |
| tone: str = "Professionnel et chaleureux", | |
| ) -> str: | |
| """Produit une reponse locale, courte, sans appel externe.""" | |
| try: | |
| numeric_note = float(note) | |
| except Exception: | |
| numeric_note = 3.0 | |
| themes = detect_themes(review_text) | |
| theme_text = format_theme_list(themes) | |
| restaurant_name = restaurant or "notre restaurant" | |
| platform_name = platform or "la plateforme" | |
| if numeric_note >= 4: | |
| return ( | |
| f"Bonjour, merci beaucoup pour votre avis et pour votre note de {numeric_note:g}/5. " | |
| f"Toute l'equipe de {restaurant_name} est ravie de lire que {theme_text} vous a plu. " | |
| f"Votre retour nous encourage a maintenir ce niveau d'exigence. " | |
| f"Au plaisir de vous accueillir de nouveau tres bientot." | |
| ) | |
| if numeric_note <= 2: | |
| return ( | |
| f"Bonjour, merci d'avoir pris le temps de partager votre experience sur {platform_name}. " | |
| f"Nous sommes sincerement desoles que votre visite a {restaurant_name} n'ait pas ete a la hauteur. " | |
| f"Votre remarque sur {theme_text} est transmise a l'equipe afin d'identifier une action concrete. " | |
| f"Nous esperons pouvoir vous offrir une meilleure experience lors d'une prochaine visite." | |
| ) | |
| return ( | |
| f"Bonjour, merci pour votre retour detaille sur {restaurant_name}. " | |
| f"Nous sommes heureux de lire les points positifs et nous prenons aussi en compte votre remarque sur {theme_text}. " | |
| f"Ces avis nous aident a progresser de facon concrete. " | |
| f"Nous serons ravis de vous revoir et de vous proposer une experience encore plus aboutie." | |
| ) | |
| def generate_review_response( | |
| review_text: str, | |
| note: float | int | str, | |
| restaurant: str, | |
| platform: str, | |
| tone: str = "Professionnel et chaleureux", | |
| use_ai: bool = True, | |
| ) -> str: | |
| """Genere une reponse prete a relire et publier.""" | |
| if not review_text or not str(review_text).strip(): | |
| return "Collez d'abord un avis client pour generer une reponse." | |
| sanitized_review = mask_personal_data(str(review_text).strip()) | |
| restaurant_name = restaurant or "le restaurant" | |
| platform_name = platform or "la plateforme" | |
| if use_ai: | |
| messages = [ | |
| { | |
| "role": "system", | |
| "content": ( | |
| "Tu aides un restaurateur a repondre a un avis client. " | |
| "Redige en francais, sans inventer de faits, avec empathie. " | |
| "La reponse doit etre courte, professionnelle, publiable et relue par un humain. " | |
| "Ne promets pas de compensation financiere. Ne cite pas de donnees personnelles." | |
| ), | |
| }, | |
| { | |
| "role": "user", | |
| "content": ( | |
| f"Restaurant: {restaurant_name}\n" | |
| f"Plateforme: {platform_name}\n" | |
| f"Note: {note}/5\n" | |
| f"Ton souhaite: {tone}\n" | |
| f"Avis client masque: {sanitized_review}\n\n" | |
| f"Genere une reponse de moins de {MAX_AI_WORDS} mots." | |
| ), | |
| }, | |
| ] | |
| ai_response = mistral_complete(messages, max_tokens=320) | |
| if ai_response: | |
| return ai_response | |
| return fallback_review_response(sanitized_review, note, restaurant_name, platform_name, tone) | |
| def summarize_restaurants(df: pd.DataFrame) -> pd.DataFrame: | |
| prepared = prepare_reviews(df) | |
| summary = ( | |
| prepared.groupby("restaurant", as_index=False) | |
| .agg( | |
| avis=("avis", "count"), | |
| note_moyenne=("note", "mean"), | |
| notes_negatives=("sentiment", lambda values: int((values == "Negatif").sum())), | |
| part_positive=("sentiment", lambda values: round((values == "Positif").mean() * 100, 1)), | |
| ) | |
| .sort_values("note_moyenne", ascending=False) | |
| ) | |
| summary["note_moyenne"] = summary["note_moyenne"].round(2) | |
| return summary.reset_index(drop=True) | |
| def summary_markdown(df: pd.DataFrame) -> str: | |
| prepared = prepare_reviews(df) | |
| by_restaurant = summarize_restaurants(prepared) | |
| total_reviews = len(prepared) | |
| restaurants = prepared["restaurant"].nunique() | |
| average = prepared["note"].mean() | |
| negative_reviews = int((prepared["sentiment"] == "Negatif").sum()) | |
| positive_share = (prepared["sentiment"] == "Positif").mean() * 100 | |
| known_dates = prepared.dropna(subset=["date"]) | |
| if not known_dates.empty: | |
| date_range = f"du {known_dates['date'].min().date()} au {known_dates['date'].max().date()}" | |
| else: | |
| date_range = "periode non renseignee" | |
| best = by_restaurant.iloc[0] | |
| weakest = by_restaurant.iloc[-1] | |
| return f""" | |
| ### Resume accessible du tableau de bord | |
| - **{total_reviews} avis analyses** sur **{restaurants} restaurants** ({date_range}). | |
| - **Note moyenne reseau : {average:.2f}/5** ; **{positive_share:.1f}% d'avis positifs** ; **{negative_reviews} avis negatifs**. | |
| - **Meilleur score : {best['restaurant']}** avec **{best['note_moyenne']:.2f}/5**. | |
| - **Etablissement a surveiller : {weakest['restaurant']}** avec **{weakest['note_moyenne']:.2f}/5**. | |
| Les graphiques ci-dessous affichent aussi les valeurs sous forme de texte pour rester lisibles sans se fier uniquement aux couleurs. | |
| """.strip() | |
| def style_figure(fig: go.Figure, title: str, legend_title: str | None = None) -> go.Figure: | |
| fig.update_layout( | |
| title={"text": title, "x": 0.02}, | |
| template="plotly_white", | |
| font={"size": 16}, | |
| margin={"t": 80, "r": 30, "b": 80, "l": 80}, | |
| hovermode="closest", | |
| legend_title_text=legend_title, | |
| ) | |
| fig.update_xaxes(title_font_size=16, tickfont_size=13, automargin=True) | |
| fig.update_yaxes(title_font_size=16, tickfont_size=13, automargin=True) | |
| return fig | |
| def empty_figure(message: str = "Aucune donnee a afficher") -> go.Figure: | |
| fig = go.Figure() | |
| fig.add_annotation(text=message, x=0.5, y=0.5, showarrow=False, font={"size": 18}) | |
| fig.update_layout(template="plotly_white", height=420, font={"size": 16}) | |
| return fig | |
| def build_figures(df: pd.DataFrame) -> tuple[go.Figure, go.Figure, go.Figure, go.Figure, go.Figure]: | |
| prepared = prepare_reviews(df) | |
| avg = summarize_restaurants(prepared) | |
| fig_avg = px.bar( | |
| avg, | |
| x="restaurant", | |
| y="note_moyenne", | |
| text="note_moyenne", | |
| labels={"restaurant": "Restaurant", "note_moyenne": "Note moyenne /5"}, | |
| ) | |
| fig_avg.update_traces(texttemplate="%{text:.2f}/5", textposition="outside", cliponaxis=False) | |
| fig_avg.update_yaxes(range=[0, 5]) | |
| style_figure(fig_avg, "1. Note moyenne par restaurant") | |
| sentiment_counts = ( | |
| prepared.groupby(["restaurant", "sentiment"], as_index=False) | |
| .size() | |
| .rename(columns={"size": "avis"}) | |
| ) | |
| fig_sentiment = px.bar( | |
| sentiment_counts, | |
| x="restaurant", | |
| y="avis", | |
| color="sentiment", | |
| barmode="group", | |
| text="avis", | |
| labels={"restaurant": "Restaurant", "avis": "Nombre d'avis", "sentiment": "Sentiment"}, | |
| ) | |
| fig_sentiment.update_traces(textposition="outside", cliponaxis=False) | |
| style_figure(fig_sentiment, "2. Sentiments compares", "Sentiment") | |
| themes = prepared.explode("themes") | |
| theme_counts = ( | |
| themes.groupby(["restaurant", "themes"], as_index=False) | |
| .size() | |
| .rename(columns={"size": "mentions", "themes": "theme"}) | |
| ) | |
| fig_themes = px.bar( | |
| theme_counts, | |
| x="restaurant", | |
| y="mentions", | |
| color="theme", | |
| barmode="stack", | |
| text="mentions", | |
| labels={"restaurant": "Restaurant", "mentions": "Mentions", "theme": "Theme"}, | |
| ) | |
| fig_themes.update_traces(textposition="inside") | |
| style_figure(fig_themes, "3. Themes mentionnes par restaurant", "Theme") | |
| weak = prepared[prepared["note"] <= 3].explode("themes") | |
| if weak.empty: | |
| weak_points = pd.DataFrame( | |
| {"restaurant": ["Tous"], "theme": ["Aucun point faible detecte"], "priorite": [0.0], "mentions": [0]} | |
| ) | |
| else: | |
| weak_points = ( | |
| weak.groupby(["restaurant", "themes"], as_index=False) | |
| .agg(mentions=("avis", "count"), note_moyenne=("note", "mean")) | |
| .rename(columns={"themes": "theme"}) | |
| ) | |
| weak_points["priorite"] = (weak_points["mentions"] * (6 - weak_points["note_moyenne"])).round(2) | |
| weak_points = weak_points.sort_values("priorite", ascending=True).tail(10) | |
| fig_weak = px.bar( | |
| weak_points, | |
| x="priorite", | |
| y="restaurant", | |
| color="theme", | |
| orientation="h", | |
| text="mentions", | |
| labels={"restaurant": "Restaurant", "priorite": "Score de priorite", "theme": "Point faible"}, | |
| ) | |
| fig_weak.update_traces(texttemplate="%{text} avis", textposition="outside", cliponaxis=False) | |
| style_figure(fig_weak, "4. Points faibles a traiter en priorite", "Theme") | |
| monthly = prepared[prepared["mois"] != "Date inconnue"] | |
| if monthly.empty: | |
| fig_monthly = empty_figure("Aucune date valide pour l'evolution mensuelle") | |
| else: | |
| monthly_stats = ( | |
| monthly.groupby(["mois", "restaurant"], as_index=False) | |
| .agg(note_moyenne=("note", "mean"), avis=("avis", "count")) | |
| .sort_values("mois") | |
| ) | |
| monthly_stats["note_moyenne"] = monthly_stats["note_moyenne"].round(2) | |
| fig_monthly = px.line( | |
| monthly_stats, | |
| x="mois", | |
| y="note_moyenne", | |
| color="restaurant", | |
| markers=True, | |
| text="note_moyenne", | |
| labels={"mois": "Mois", "note_moyenne": "Note moyenne /5", "restaurant": "Restaurant"}, | |
| ) | |
| fig_monthly.update_traces(texttemplate="%{text:.2f}", textposition="top center") | |
| fig_monthly.update_yaxes(range=[0, 5]) | |
| style_figure(fig_monthly, "5. Evolution mensuelle de la note moyenne", "Restaurant") | |
| return fig_avg, fig_sentiment, fig_themes, fig_weak, fig_monthly | |
| def top_negative_theme(prepared: pd.DataFrame, restaurant: str) -> str: | |
| subset = prepared[(prepared["restaurant"] == restaurant) & (prepared["note"] <= 3)].explode("themes") | |
| if subset.empty: | |
| return "aucun point faible majeur" | |
| counts = subset["themes"].value_counts() | |
| return str(counts.index[0]).lower() | |
| def deterministic_team_briefing(df: pd.DataFrame) -> str: | |
| prepared = prepare_reviews(df) | |
| summary = summarize_restaurants(prepared) | |
| best = summary.iloc[0] | |
| weakest = summary.iloc[-1] | |
| lines = [ | |
| "### Bilan d'equipe actionnable", | |
| "", | |
| f"**Priorite reseau :** conserver les bonnes pratiques de {best['restaurant']} ({best['note_moyenne']:.2f}/5) et accompagner {weakest['restaurant']} ({weakest['note_moyenne']:.2f}/5).", | |
| "", | |
| "**Lecture par restaurant :**", | |
| ] | |
| for _, row in summary.iterrows(): | |
| restaurant = row["restaurant"] | |
| weak_theme = top_negative_theme(prepared, restaurant) | |
| lines.append( | |
| f"- **{restaurant}** : {row['avis']} avis, note moyenne {row['note_moyenne']:.2f}/5, {row['notes_negatives']} avis negatifs. Point a suivre : {weak_theme}." | |
| ) | |
| lines.extend( | |
| [ | |
| "", | |
| "**Actions conseillees pour le briefing du lundi :**", | |
| "1. Relire les avis negatifs avant publication des reponses.", | |
| "2. Choisir une action simple par restaurant : service, attente, prix, cuisine ou horaires selon le theme dominant.", | |
| "3. Mesurer l'effet le mois suivant avec la courbe d'evolution.", | |
| "", | |
| "L'IA propose une aide a la decision ; le restaurateur relit, ajuste et decide.", | |
| ] | |
| ) | |
| return "\n".join(lines) | |
| def generate_team_briefing(df: pd.DataFrame, use_ai: bool = True) -> str: | |
| prepared = prepare_reviews(df) | |
| if use_ai: | |
| summary = summarize_restaurants(prepared) | |
| themes = ( | |
| prepared.explode("themes") | |
| .groupby(["restaurant", "themes"], as_index=False) | |
| .size() | |
| .rename(columns={"size": "mentions"}) | |
| .sort_values(["restaurant", "mentions"], ascending=[True, False]) | |
| ) | |
| aggregate_payload = { | |
| "restaurants": summary.to_dict(orient="records"), | |
| "themes": themes.head(30).to_dict(orient="records"), | |
| } | |
| messages = [ | |
| { | |
| "role": "system", | |
| "content": ( | |
| "Tu aides un restaurateur multi-sites a preparer un briefing d'equipe. " | |
| "Utilise uniquement les donnees agregees fournies, sans inventer d'avis. " | |
| "Produit un bilan court, actionnable et prudent." | |
| ), | |
| }, | |
| { | |
| "role": "user", | |
| "content": f"Donnees agregees: {aggregate_payload}\nRedige un bilan en francais avec 3 actions prioritaires.", | |
| }, | |
| ] | |
| ai_response = mistral_complete(messages, max_tokens=450) | |
| if ai_response: | |
| return ai_response | |
| return deterministic_team_briefing(prepared) | |
| def build_dashboard(file_input: object | None = None, use_ai: bool = True): | |
| """Construit les sorties de l'onglet Comparer.""" | |
| try: | |
| df = load_and_prepare(file_input) | |
| figures = build_figures(df) | |
| return (summary_markdown(df), *figures, generate_team_briefing(df, use_ai=use_ai)) | |
| except Exception as exc: | |
| message = ( | |
| "### Erreur d'analyse\n\n" | |
| f"{exc}\n\n" | |
| "Colonnes attendues : date, restaurant, note, plateforme (optionnelle), avis." | |
| ) | |
| empty = empty_figure(str(exc)) | |
| return (message, empty, empty, empty, empty, empty, "Bilan indisponible tant que le CSV n'est pas valide.") | |
| def add_check(results: list[CheckResult], name: str, ok: bool, detail: str) -> None: | |
| results.append(CheckResult(name=name, ok=bool(ok), detail=detail)) | |
| def run_quality_checks() -> list[CheckResult]: | |
| """Execute 7 controles visibles dans les logs et dans l'interface.""" | |
| results: list[CheckResult] = [] | |
| try: | |
| raw = load_reviews(DEFAULT_CSV_PATH) | |
| missing = REQUIRED_COLUMNS - set(raw.columns) | |
| add_check( | |
| results, | |
| "1. CSV exemple lisible et colonnes obligatoires", | |
| not missing, | |
| f"{len(raw)} lignes chargees ; colonnes: {', '.join(raw.columns)}", | |
| ) | |
| except Exception as exc: | |
| raw = pd.DataFrame() | |
| add_check(results, "1. CSV exemple lisible et colonnes obligatoires", False, str(exc)) | |
| try: | |
| prepared = prepare_reviews(raw) | |
| add_check( | |
| results, | |
| "2. Jeu exemple coherent", | |
| len(prepared) == 36 and prepared["restaurant"].nunique() == 3, | |
| f"{len(prepared)} avis ; {prepared['restaurant'].nunique()} restaurants", | |
| ) | |
| except Exception as exc: | |
| prepared = pd.DataFrame() | |
| add_check(results, "2. Jeu exemple coherent", False, str(exc)) | |
| try: | |
| sentiments = set(prepared["sentiment"].dropna().unique()) | |
| add_check( | |
| results, | |
| "3. Classification sentiments", | |
| {"Positif", "Mitige", "Negatif"}.issubset(sentiments), | |
| f"Sentiments trouves: {', '.join(sorted(sentiments))}", | |
| ) | |
| except Exception as exc: | |
| add_check(results, "3. Classification sentiments", False, str(exc)) | |
| try: | |
| themes = prepared.explode("themes")["themes"].dropna().unique().tolist() | |
| add_check( | |
| results, | |
| "4. Detection des themes", | |
| len(themes) >= 5, | |
| f"Themes trouves: {', '.join(sorted(themes))}", | |
| ) | |
| except Exception as exc: | |
| add_check(results, "4. Detection des themes", False, str(exc)) | |
| try: | |
| reply = generate_review_response( | |
| "Service lent et plat froid, tres decu.", | |
| 1, | |
| "Le Normand - Cabourg", | |
| "Google", | |
| use_ai=False, | |
| ) | |
| add_check( | |
| results, | |
| "5. Reponse locale sans cle API", | |
| len(reply) > 80 and "desoles" in strip_accents(reply), | |
| reply[:180], | |
| ) | |
| except Exception as exc: | |
| add_check(results, "5. Reponse locale sans cle API", False, str(exc)) | |
| try: | |
| figures = build_figures(prepared) | |
| add_check( | |
| results, | |
| "6. Cinq graphiques Plotly", | |
| len(figures) == 5 and all(isinstance(fig, go.Figure) for fig in figures), | |
| f"{len(figures)} figures generees", | |
| ) | |
| except Exception as exc: | |
| add_check(results, "6. Cinq graphiques Plotly", False, str(exc)) | |
| try: | |
| dashboard = build_dashboard(DEFAULT_CSV_PATH, use_ai=False) | |
| add_check( | |
| results, | |
| "7. Tableau de bord et bilan generables", | |
| len(dashboard) == 7 and "Bilan" in dashboard[-1], | |
| "Resume, 5 graphiques et bilan equipe generes.", | |
| ) | |
| except Exception as exc: | |
| add_check(results, "7. Tableau de bord et bilan generables", False, str(exc)) | |
| return results | |
| def checks_to_table(results: list[CheckResult]) -> list[list[str]]: | |
| return [[check.name, check.status, check.detail] for check in results] | |
| def run_tests_for_ui(): | |
| results = run_quality_checks() | |
| passed = sum(check.ok for check in results) | |
| total = len(results) | |
| summary = f"### Resultat des tests\n\n**{passed}/{total} tests OK.** Les tests sont executables aussi en local avec `python test_app.py`." | |
| return checks_to_table(results), summary | |
| def print_startup_checks(results: list[CheckResult]) -> None: | |
| print("\n[AvisIA] Tests automatiques au demarrage") | |
| for check in results: | |
| icon = "OK" if check.ok else "ECHEC" | |
| print(f"[AvisIA] {icon} - {check.name}: {check.detail}") | |
| passed = sum(check.ok for check in results) | |
| print(f"[AvisIA] Synthese: {passed}/{len(results)} tests OK\n") | |
| def method_markdown() -> str: | |
| return """ | |
| ## Methode, conformite et limites | |
| ### Donnees et RGPD | |
| - L'application est **stateless** : elle analyse le fichier pendant la session et ne cree pas de base de donnees. | |
| - Les avis sont des avis clients publics, mais la minimisation reste appliquee : le texte est masque pour les emails et telephones avant tout appel IA. | |
| - La cle `MISTRAL_API_KEY` doit etre stockee dans **Settings -> Variables and secrets** du Space Hugging Face, jamais dans le code. | |
| ### IA Act et usage responsable | |
| - Usage classe comme risque limite : aide a la redaction et a la decision, sans decision automatique critique. | |
| - Transparence : l'interface rappelle que l'IA propose et que le restaurateur relit, ajuste et publie. | |
| - Human-in-the-loop : aucune reponse n'est publiee automatiquement. | |
| ### Qualite et tests | |
| - 7 controles automatiques sont lances au demarrage et visibles dans les logs. | |
| - L'onglet Tests permet de relancer ces controles dans l'interface. | |
| - Le fichier `test_app.py` permet une verification locale avant de pousser sur Hugging Face. | |
| ### Accessibilite | |
| - Police de base 16 px, labels explicites, valeurs affichees sur les graphiques et resume textuel du tableau de bord. | |
| - Les graphiques ne dependent pas uniquement de la couleur : les valeurs sont ecrites sur les barres ou les points. | |
| ### Limites connues | |
| - La detection des themes est volontairement simple, transparente et auditable ; elle peut etre enrichie avec un modele specialise plus tard. | |
| - Les reponses IA doivent rester relues par un humain, surtout pour les avis sensibles. | |
| """.strip() | |
| def create_interface() -> gr.Blocks: | |
| restaurants = get_default_restaurants() | |
| blocks_kwargs = {"title": "Avis IA Resto"} | |
| if int(gr.__version__.split(".")[0]) < 6: | |
| blocks_kwargs["css"] = CUSTOM_CSS | |
| with gr.Blocks(**blocks_kwargs) as demo: | |
| gr.Markdown( | |
| """ | |
| # Avis'IA Resto - Pilotez vos avis clients sur tous vos restaurants | |
| <div class="helper-box"> | |
| Collez un avis pour generer une reponse professionnelle, ou chargez un CSV pour comparer vos restaurants. Aucune donnee n'est conservee par l'application. | |
| </div> | |
| """ | |
| ) | |
| with gr.Tabs(): | |
| with gr.Tab("1. Repondre"): | |
| gr.Markdown("## Generer une reponse prete a relire et publier") | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| restaurant_input = gr.Dropdown( | |
| choices=restaurants, | |
| value=restaurants[0] if restaurants else "Le Normand - Caen", | |
| label="Restaurant concerne", | |
| allow_custom_value=True, | |
| ) | |
| platform_input = gr.Dropdown( | |
| choices=["Google", "TripAdvisor", "TheFork", "Facebook", "Autre"], | |
| value="Google", | |
| label="Plateforme", | |
| allow_custom_value=True, | |
| ) | |
| note_input = gr.Slider(1, 5, value=4, step=1, label="Note client sur 5") | |
| tone_input = gr.Dropdown( | |
| choices=[ | |
| "Professionnel et chaleureux", | |
| "Empathique et sobre", | |
| "Premium et attentionne", | |
| "Court et direct", | |
| ], | |
| value="Professionnel et chaleureux", | |
| label="Ton souhaite", | |
| ) | |
| review_input = gr.Textbox( | |
| label="Avis client", | |
| lines=7, | |
| placeholder="Collez ici l'avis Google, TripAdvisor ou TheFork...", | |
| ) | |
| generate_button = gr.Button("Generer la reponse", variant="primary") | |
| gr.Examples( | |
| examples=EXAMPLE_REVIEWS, | |
| inputs=[review_input, note_input, restaurant_input, platform_input], | |
| label="Exemples cliquables", | |
| ) | |
| with gr.Column(scale=1): | |
| response_output = gr.Textbox( | |
| label="Reponse proposee par l'IA ou par le mode local", | |
| lines=12, | |
| ) | |
| gr.Markdown( | |
| """ | |
| <div class="helper-box small-note"> | |
| L'IA propose ; le restaurateur relit, ajuste et publie. Ne publiez jamais automatiquement une reponse sensible. | |
| </div> | |
| """ | |
| ) | |
| generate_button.click( | |
| fn=generate_review_response, | |
| inputs=[review_input, note_input, restaurant_input, platform_input, tone_input], | |
| outputs=response_output, | |
| ) | |
| with gr.Tab("2. Comparer"): | |
| gr.Markdown("## Tableau de bord comparatif multi-restaurants") | |
| csv_input = gr.File(label="Chargez un CSV d'avis", file_types=[".csv"]) | |
| with gr.Row(): | |
| analyze_button = gr.Button("Analyser le CSV charge", variant="primary") | |
| example_button = gr.Button("Utiliser le CSV exemple") | |
| summary_output = gr.Markdown() | |
| fig_1 = gr.Plot(label="Note moyenne par restaurant") | |
| fig_2 = gr.Plot(label="Sentiments compares") | |
| fig_3 = gr.Plot(label="Themes mentionnes") | |
| fig_4 = gr.Plot(label="Points faibles") | |
| fig_5 = gr.Plot(label="Evolution mensuelle") | |
| briefing_output = gr.Markdown(label="Bilan d'equipe") | |
| dashboard_outputs = [summary_output, fig_1, fig_2, fig_3, fig_4, fig_5, briefing_output] | |
| analyze_button.click(fn=build_dashboard, inputs=csv_input, outputs=dashboard_outputs) | |
| example_button.click(fn=lambda: build_dashboard(None), inputs=None, outputs=dashboard_outputs) | |
| demo.load(fn=lambda: build_dashboard(None), inputs=None, outputs=dashboard_outputs) | |
| with gr.Tab("3. Tests"): | |
| gr.Markdown("## Tests en direct avant deploiement") | |
| test_button = gr.Button("Lancer les 7 verifications", variant="primary") | |
| tests_table = gr.Dataframe( | |
| headers=["Test", "Resultat", "Detail"], | |
| datatype=["str", "str", "str"], | |
| label="Controle qualite", | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| tests_summary = gr.Markdown() | |
| test_button.click(fn=run_tests_for_ui, inputs=None, outputs=[tests_table, tests_summary]) | |
| demo.load(fn=run_tests_for_ui, inputs=None, outputs=[tests_table, tests_summary]) | |
| with gr.Tab("4. Methode"): | |
| gr.Markdown(method_markdown()) | |
| return demo | |
| STARTUP_TEST_RESULTS = run_quality_checks() | |
| print_startup_checks(STARTUP_TEST_RESULTS) | |
| demo = create_interface() | |
| if __name__ == "__main__": | |
| try: | |
| launch_kwargs = {"css": CUSTOM_CSS} if int(gr.__version__.split(".")[0]) >= 6 else {} | |
| demo.launch(**launch_kwargs) | |
| except Exception: | |
| traceback.print_exc() | |
| raise | |