""" 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; } """ @dataclass class CheckResult: name: str ok: bool detail: str @property 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