| """ |
| Avis'IA Resto - application Gradio pour Hugging Face Spaces. |
| """ |
|
|
| 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: |
| from mistralai.client import Mistral |
| except Exception: |
| try: |
| from mistralai import Mistral |
| except Exception: |
| Mistral = None |
|
|
| APP_DIR = Path(__file__).resolve().parent |
| DEFAULT_CSV_PATH = APP_DIR / "avis_restaurant_exemple.csv" |
| REQUIRED_COLUMNS = {"date", "restaurant", "note", "avis"} |
| MISTRAL_MODEL = os.getenv("MISTRAL_MODEL", "mistral-small-latest") |
| MAX_AI_WORDS = 120 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| CUSTOM_CSS = """ |
| /* ═══════════════════════════════════════════════════════════════ |
| AVIS'IA RESTO — CSS thème clair, contrastes RGAA AA garantis |
| Ratios vérifiés : primaire #5B21B6/blanc = 8.6:1 |
| texte #111827/blanc = 18.1:1 |
| secondaire #374151 = 10.7:1 |
| ═══════════════════════════════════════════════════════════════ */ |
| |
| /* ── 1. Neutraliser TOUTES les variables Gradio (thème sombre) ── */ |
| :root, |
| .dark, |
| .light { |
| /* Verrou navigateur : les composants natifs (scrollbars, selects, |
| calendriers) restent en rendu clair (RGAA : cohérence visuelle) */ |
| color-scheme: light !important; |
| |
| /* Fonds */ |
| --body-background-fill: #FAF9FC !important; |
| --background-fill-primary: #FFFFFF !important; |
| --background-fill-secondary: #F3F1F8 !important; |
| --background-fill-tertiary: #ECE8F5 !important; |
| --block-background-fill: #FFFFFF !important; |
| --block-border-color: #C7BEDD !important; |
| --panel-background-fill: #FFFFFF !important; |
| --panel-border-color: #C7BEDD !important; |
| --input-background-fill: #FFFFFF !important; |
| --input-background-fill-focus: #FFFFFF !important; |
| --input-border-color: #B9ACD6 !important; |
| --input-border-color-focus: #5B21B6 !important; |
| --table-row-focus: #F0EBFB !important; |
| --code-background-fill: #F0EBFB !important; |
| --color-accent-soft: #F0EBFB !important; |
| --color-accent: #5B21B6 !important; |
| |
| /* Textes */ |
| --body-text-color: #111827 !important; |
| --body-text-color-subdued: #374151 !important; |
| --block-label-text-color: #111827 !important; |
| --block-title-text-color: #111827 !important; |
| --input-placeholder-color: #6B7280 !important; |
| --prose-text-color: #111827 !important; |
| --prose-header-text-color: #5B21B6 !important; |
| --link-text-color: #5B21B6 !important; |
| --link-text-color-hover: #3B0764 !important; |
| --link-text-color-visited: #5B21B6 !important; |
| --link-text-color-active: #3B0764 !important; |
| --neutral-100: #ECE8F5 !important; |
| --neutral-200: #DCD3EE !important; |
| --neutral-300: #C7BEDD !important; |
| --neutral-400: #9C8FC2 !important; |
| --neutral-50: #F3F1F8 !important; |
| --neutral-500: #6B7280 !important; |
| --neutral-600: #374151 !important; |
| --neutral-700: #374151 !important; |
| --neutral-800: #111827 !important; |
| --neutral-900: #111827 !important; |
| --neutral-950: #111827 !important; |
| |
| /* Boutons */ |
| --button-primary-background-fill: #5B21B6 !important; |
| --button-primary-background-fill-hover: #3B0764 !important; |
| --button-primary-text-color: #FFFFFF !important; |
| --button-secondary-background-fill: #FFFFFF !important; |
| --button-secondary-background-fill-hover:#F0EBFB !important; |
| --button-secondary-text-color: #5B21B6 !important; |
| --button-secondary-border-color: #5B21B6 !important; |
| --button-cancel-background-fill: #FEF2F2 !important; |
| --button-cancel-text-color: #991B1B !important; |
| |
| /* Checkbox/radio/slider */ |
| --checkbox-background-color: #FFFFFF !important; |
| --checkbox-background-color-focus: #F0EBFB !important; |
| --checkbox-background-color-hover: #F0EBFB !important; |
| --checkbox-background-color-selected:#5B21B6 !important; |
| --checkbox-border-color: #B9ACD6 !important; |
| --checkbox-border-color-focus: #5B21B6 !important; |
| --checkbox-border-color-hover: #5B21B6 !important; |
| --checkbox-border-color-selected: #5B21B6 !important; |
| --checkbox-label-text-color: #111827 !important; |
| --slider-color: #5B21B6 !important; |
| |
| /* Tabs */ |
| --tab-text-color: #374151 !important; |
| --tab-text-color-selected: #FFFFFF !important; |
| --tab-background-color-selected: #5B21B6 !important; |
| |
| /* Tokens divers */ |
| --shadow-drop: 0 2px 6px rgba(76,29,149,0.10) !important; |
| --shadow-drop-lg: 0 8px 24px rgba(76,29,149,0.16) !important; |
| --shadow-spread: 2px !important; |
| --border-color-accent: #5B21B6 !important; |
| --border-color-primary: #C7BEDD !important; |
| --color-border-primary: #C7BEDD !important; |
| --loader-color: #5B21B6 !important; |
| |
| /* Variables personnalisées */ |
| --primary: #5B21B6; |
| --primary-dark: #3B0764; |
| --primary-light: #EDE9FE; |
| --accent: #3730A3; |
| --text: #111827; |
| --text-secondary:#374151; |
| --border: #C7BEDD; |
| --border-strong: #9C8FC2; |
| --bg: #FFFFFF; |
| --bg-subtle: #F3F1F8; |
| --bg-purple: #F0EBFB; |
| --error-bg: #FEF2F2; |
| --error-text: #991B1B; |
| --radius: 10px; |
| --shadow-card: 0 2px 8px rgba(76,29,149,0.09), 0 1px 2px rgba(17,24,39,0.06); |
| --shadow-card-hover: 0 6px 18px rgba(76,29,149,0.16), 0 2px 4px rgba(17,24,39,0.08); |
| } |
| |
| /* ── 2. Base ── */ |
| *, *::before, *::after { box-sizing: border-box; } |
| |
| html, body { |
| background: #FAF9FC !important; |
| color: #111827 !important; |
| } |
| |
| .gradio-container, |
| .gradio-container > *, |
| .wrap, |
| .contain { |
| background: #FAF9FC !important; |
| color: #111827 !important; |
| font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif !important; |
| font-size: 16px !important; |
| max-width: 1200px !important; |
| margin-left: auto !important; |
| margin-right: auto !important; |
| } |
| |
| /* ── 3. Tous les blocs Gradio — relief net ── */ |
| .block, |
| .form, |
| .box, |
| .panel, |
| .gr-form, |
| .gr-box, |
| .gr-panel { |
| background: #FFFFFF !important; |
| color: #111827 !important; |
| border: 1.5px solid var(--border) !important; |
| border-radius: var(--radius) !important; |
| box-shadow: var(--shadow-card) !important; |
| } |
| |
| /* ── 4. En-tête application — dégradé + relief ── */ |
| .app-header { |
| background: linear-gradient(135deg, #6D28D9 0%, #5B21B6 55%, #4C1D95 100%) !important; |
| padding: 1.85rem 2.1rem !important; |
| border-radius: 14px !important; |
| margin-bottom: 1.6rem !important; |
| box-shadow: 0 10px 28px rgba(76,29,149,0.30), inset 0 1px 0 rgba(255,255,255,0.12) !important; |
| border: 1px solid #4C1D95 !important; |
| } |
| .app-header h1 { |
| color: #FFFFFF !important; |
| font-size: 2.4rem !important; |
| font-weight: 800 !important; |
| margin: 0 0 0.3rem 0 !important; |
| line-height: 1.2 !important; |
| background: transparent !important; |
| text-shadow: 0 2px 6px rgba(0,0,0,0.18) !important; |
| } |
| .app-header p { |
| color: rgba(255,255,255,0.95) !important; |
| font-size: 1.05rem !important; |
| margin: 0 !important; |
| background: transparent !important; |
| } |
| |
| /* ── 5. Onglets — bandeau plus marqué ── */ |
| .tab-nav { |
| border-bottom: 3px solid #5B21B6 !important; |
| background: #FFFFFF !important; |
| border-radius: 10px 10px 0 0 !important; |
| box-shadow: 0 1px 4px rgba(76,29,149,0.08) !important; |
| padding: 0.3rem 0.3rem 0 0.3rem !important; |
| } |
| .tab-nav button { |
| background: #FFFFFF !important; |
| color: #374151 !important; |
| border: 1.5px solid transparent !important; |
| font-weight: 700 !important; |
| font-size: 0.95rem !important; |
| padding: 0.7rem 1.2rem !important; |
| border-radius: 8px 8px 0 0 !important; |
| margin-right: 0.2rem !important; |
| transition: all 0.15s ease !important; |
| } |
| .tab-nav button:hover { |
| background: #F0EBFB !important; |
| color: #5B21B6 !important; |
| border-color: #DCD3EE !important; |
| } |
| .tab-nav button.selected, |
| .tab-nav button[aria-selected="true"] { |
| background: linear-gradient(180deg, #6D28D9 0%, #5B21B6 100%) !important; |
| color: #FFFFFF !important; |
| border-color: #4C1D95 !important; |
| box-shadow: 0 -2px 8px rgba(76,29,149,0.25) !important; |
| } |
| |
| /* ── 6. Labels et titres de blocs ── */ |
| label, |
| .label-wrap, |
| .label-wrap span, |
| .block-label, |
| span.svelte-1gfkn6j, |
| .svelte-pbokmc { |
| color: #111827 !important; |
| font-weight: 700 !important; |
| background: transparent !important; |
| } |
| |
| /* ── 7. Inputs, textareas, selects — contours nets ── */ |
| input, |
| textarea, |
| select, |
| .input, |
| .textarea { |
| background: #FFFFFF !important; |
| color: #111827 !important; |
| border: 1.75px solid var(--border-strong) !important; |
| border-radius: var(--radius) !important; |
| box-shadow: inset 0 1px 2px rgba(17,24,39,0.04) !important; |
| transition: border-color 0.15s ease, box-shadow 0.15s ease !important; |
| } |
| input::placeholder, textarea::placeholder { color: #6B7280 !important; } |
| input:focus, textarea:focus, select:focus { |
| border-color: #5B21B6 !important; |
| outline: 3px solid rgba(91,33,182,0.25) !important; |
| outline-offset: 0 !important; |
| box-shadow: 0 0 0 4px rgba(91,33,182,0.10) !important; |
| } |
| |
| /* Slider */ |
| input[type="range"] { background: transparent !important; } |
| input[type="range"]::-webkit-slider-thumb { |
| background: #5B21B6 !important; |
| box-shadow: 0 2px 6px rgba(76,29,149,0.4) !important; |
| border: 2px solid #FFFFFF !important; |
| } |
| input[type="range"]::-moz-range-thumb { |
| background: #5B21B6 !important; |
| box-shadow: 0 2px 6px rgba(76,29,149,0.4) !important; |
| border: 2px solid #FFFFFF !important; |
| } |
| input[type="range"]::-webkit-slider-runnable-track { background: #C7BEDD !important; } |
| |
| /* ── 8. Boutons — relief et dégradé ── */ |
| button, |
| .btn { |
| background: #FFFFFF !important; |
| color: #111827 !important; |
| border: 1.5px solid var(--border-strong) !important; |
| } |
| button[variant="primary"], |
| .gr-button-primary, |
| button.primary { |
| background: linear-gradient(180deg, #6D28D9 0%, #5B21B6 100%) !important; |
| color: #FFFFFF !important; |
| border: 1px solid #4C1D95 !important; |
| border-radius: var(--radius) !important; |
| padding: 0.65rem 1.5rem !important; |
| font-weight: 700 !important; |
| cursor: pointer !important; |
| box-shadow: 0 4px 12px rgba(76,29,149,0.28), inset 0 1px 0 rgba(255,255,255,0.15) !important; |
| transition: transform 0.1s ease, box-shadow 0.15s ease !important; |
| } |
| button[variant="primary"]:hover { |
| background: linear-gradient(180deg, #7C3AED 0%, #6D28D9 100%) !important; |
| box-shadow: 0 6px 18px rgba(76,29,149,0.36), inset 0 1px 0 rgba(255,255,255,0.18) !important; |
| transform: translateY(-1px) !important; |
| } |
| button[variant="primary"]:active { transform: translateY(0) !important; } |
| button[variant="primary"]:focus { |
| outline: 3px solid rgba(91,33,182,0.4) !important; |
| outline-offset: 2px !important; |
| } |
| button[variant="secondary"], |
| .gr-button-secondary, |
| button.secondary { |
| background: #FFFFFF !important; |
| color: #5B21B6 !important; |
| border: 2px solid #5B21B6 !important; |
| border-radius: var(--radius) !important; |
| padding: 0.6rem 1.3rem !important; |
| font-weight: 700 !important; |
| cursor: pointer !important; |
| box-shadow: 0 2px 6px rgba(76,29,149,0.10) !important; |
| transition: all 0.15s ease !important; |
| } |
| button[variant="secondary"]:hover { |
| background: #F0EBFB !important; |
| box-shadow: 0 4px 12px rgba(76,29,149,0.18) !important; |
| transform: translateY(-1px) !important; |
| } |
| |
| /* ── 9. Boîtes info / avertissement — bandeau plus marqué ── */ |
| .info-box { |
| background: linear-gradient(135deg, #F0EBFB 0%, #E6DCF8 100%) !important; |
| border: 1.5px solid #C7BEDD !important; |
| border-left: 5px solid #5B21B6 !important; |
| border-radius: 0 var(--radius) var(--radius) 0 !important; |
| padding: 0.85rem 1.1rem !important; |
| margin: 0.6rem 0 !important; |
| color: #3730A3 !important; |
| box-shadow: 0 2px 8px rgba(76,29,149,0.10) !important; |
| } |
| .info-box * { color: #3730A3 !important; background: transparent !important; } |
| .info-box strong { color: #3730A3 !important; font-weight: 700 !important; } |
| |
| .warn-box { |
| background: linear-gradient(135deg, #FEF2F2 0%, #FCE4E4 100%) !important; |
| border: 1.5px solid #F3B6B6 !important; |
| border-left: 5px solid #DC2626 !important; |
| border-radius: 0 var(--radius) var(--radius) 0 !important; |
| padding: 0.85rem 1.1rem !important; |
| margin: 0.6rem 0 !important; |
| color: #991B1B !important; |
| box-shadow: 0 2px 8px rgba(220,38,38,0.10) !important; |
| } |
| .warn-box * { color: #991B1B !important; background: transparent !important; } |
| .warn-box strong { color: #991B1B !important; font-weight: 700 !important; } |
| |
| /* ── 10. Titres markdown ── */ |
| h1, h2, h3, h4, h5, h6 { color: #5B21B6 !important; background: transparent !important; } |
| h1 { font-size: 1.6rem !important; font-weight: 800 !important; } |
| h2 { |
| font-size: 1.3rem !important; |
| font-weight: 800 !important; |
| border-bottom: 3px solid #C7BEDD !important; |
| padding-bottom: 0.35rem !important; |
| } |
| h3 { font-size: 1.05rem !important; font-weight: 700 !important; } |
| p, li, span { color: #111827 !important; } |
| strong, b { color: #111827 !important; font-weight: 700 !important; } |
| a { color: #5B21B6 !important; } |
| |
| /* ── 11. Tableaux Gradio (dataframe) — bordures nettes ── */ |
| .dataframe, |
| .table-wrap, |
| table { |
| background: #FFFFFF !important; |
| width: 100% !important; |
| border-collapse: collapse !important; |
| border: 1.5px solid var(--border-strong) !important; |
| border-radius: var(--radius) !important; |
| overflow: hidden !important; |
| box-shadow: var(--shadow-card) !important; |
| } |
| thead, thead tr, th { |
| background: linear-gradient(180deg, #6D28D9 0%, #5B21B6 100%) !important; |
| color: #FFFFFF !important; |
| font-weight: 700 !important; |
| padding: 0.6rem 0.8rem !important; |
| text-align: left !important; |
| border: none !important; |
| border-bottom: 2px solid #4C1D95 !important; |
| } |
| tbody tr td, |
| td { |
| background: #FFFFFF !important; |
| color: #111827 !important; |
| padding: 0.55rem 0.8rem !important; |
| border-bottom: 1.5px solid #E5DFF2 !important; |
| } |
| tbody tr:nth-child(even) td { background: #F8F6FC !important; } |
| tbody tr:hover td { background: #F0EBFB !important; } |
| |
| /* ── 12. Dropdown / Select wrapper Gradio ── */ |
| .wrap-inner, |
| .dropdown, |
| ul.options, |
| li.item { |
| background: #FFFFFF !important; |
| color: #111827 !important; |
| border: 1.5px solid var(--border-strong) !important; |
| box-shadow: var(--shadow-card-hover) !important; |
| } |
| li.item:hover, .item.selected { |
| background: #F0EBFB !important; |
| color: #5B21B6 !important; |
| } |
| |
| /* ── 13. GRAPHIQUES PLOTLY — isolation totale ── |
| On ne touche PAS aux éléments svg/canvas. |
| Le fond blanc est géré par paper_bgcolor dans Python. */ |
| .js-plotly-plot { background: #FFFFFF !important; } |
| .js-plotly-plot .plotly, |
| .js-plotly-plot svg, |
| .js-plotly-plot canvas, |
| .svg-container, |
| .main-svg { |
| background: transparent !important; |
| } |
| /* Zone Gradio autour du graphique — encadrement marqué */ |
| .gr-plot, |
| .gr-plot > *:not(.js-plotly-plot) { |
| background: #FFFFFF !important; |
| border: 1.75px solid var(--border-strong) !important; |
| border-radius: var(--radius) !important; |
| box-shadow: var(--shadow-card) !important; |
| } |
| |
| /* ── 14. Exemples cliquables pleine largeur ── */ |
| .examples-holder, .gr-examples, .examples { |
| width: 100% !important; |
| max-width: 100% !important; |
| background: #FFFFFF !important; |
| border: 1.5px solid var(--border-strong) !important; |
| border-radius: var(--radius) !important; |
| box-shadow: var(--shadow-card) !important; |
| padding: 0.4rem !important; |
| } |
| .examples-holder table, .gr-examples table, .examples table { |
| width: 100% !important; |
| table-layout: fixed !important; |
| border: none !important; |
| box-shadow: none !important; |
| } |
| .examples-holder td:first-child, |
| .gr-examples td:first-child, |
| .examples td:first-child { |
| width: 55% !important; |
| white-space: normal !important; |
| } |
| |
| /* ── 15. Fichier upload — zone de dépôt marquée ── */ |
| .file-preview, .upload-btn, .file-component { |
| background: #FFFFFF !important; |
| color: #111827 !important; |
| border: 1.75px dashed var(--border-strong) !important; |
| border-radius: var(--radius) !important; |
| } |
| .file-component:hover { |
| border-color: #5B21B6 !important; |
| background: #FAF8FE !important; |
| } |
| |
| /* ── 16. Scrollbars ── */ |
| ::-webkit-scrollbar { width: 9px; background: #F3F1F8; } |
| ::-webkit-scrollbar-thumb { background: #B9ACD6; border-radius: 5px; border: 2px solid #F3F1F8; } |
| ::-webkit-scrollbar-thumb:hover { background: #9C8FC2; } |
| |
| /* ── 17. Focus visible RGAA ── */ |
| :focus-visible { |
| outline: 3px solid #5B21B6 !important; |
| outline-offset: 2px !important; |
| } |
| |
| /* ── 18. Accordion (sélecteur CSV onglet Répondre) — cadre marqué ── */ |
| .accordion, |
| .label-wrap.accordion { |
| background: #FFFFFF !important; |
| border: 1.75px solid var(--border-strong) !important; |
| border-radius: var(--radius) !important; |
| margin-bottom: 1.1rem !important; |
| box-shadow: var(--shadow-card) !important; |
| } |
| .accordion .label-wrap span, |
| .accordion > .label-wrap { |
| color: #5B21B6 !important; |
| font-weight: 800 !important; |
| } |
| .accordion:hover { box-shadow: var(--shadow-card-hover) !important; } |
| |
| /* ── 19. Statut du sélecteur d'avis — bandeau net ── */ |
| .picker-status { |
| background: linear-gradient(135deg, #F8F6FC 0%, #F0EBFB 100%) !important; |
| border: 1.5px solid var(--border-strong) !important; |
| border-left: 4px solid #5B21B6 !important; |
| border-radius: var(--radius) !important; |
| padding: 0.7rem 1rem !important; |
| font-size: 0.92rem !important; |
| box-shadow: 0 2px 6px rgba(76,29,149,0.08) !important; |
| } |
| .picker-status h3 { |
| font-size: 0.95rem !important; |
| margin: 0 0 0.2rem 0 !important; |
| border-bottom: none !important; |
| padding-bottom: 0 !important; |
| } |
| .picker-status p { margin: 0 !important; color: #374151 !important; } |
| |
| /* ── 20. Cards/Tabs internes (Row, Column) — légère séparation ── */ |
| .gradio-container .form > .block { |
| box-shadow: none !important; |
| } |
| |
| /* ── 21. Responsive ── */ |
| @media (max-width: 700px) { |
| .app-header h1 { font-size: 1.6rem !important; } |
| .tab-nav button { padding: 0.5rem 0.7rem !important; font-size: 0.82rem !important; } |
| } |
| |
| /* ═══════════════════════════════════════════════════════════════ |
| 22. TABLEAUX (Dataframe) — traitement pro anti-mode-sombre |
| Les Dataframe Gradio ont leurs propres variables internes qui |
| échappent aux réglages globaux. On force ici chaque élément. |
| Contrastes : en-tête blanc/#5B21B6 = 8.6:1, texte #111827 = 18:1 |
| ═══════════════════════════════════════════════════════════════ */ |
| |
| /* Conteneur du tableau */ |
| .gradio-container .table-wrap, |
| .gradio-container [class*="table"], |
| .gradio-container .dataframe { |
| background: #FFFFFF !important; |
| border: 1.5px solid #C7BEDD !important; |
| border-radius: 10px !important; |
| overflow: hidden !important; |
| } |
| |
| /* Table elle-même */ |
| .gradio-container table { |
| background: #FFFFFF !important; |
| color: #111827 !important; |
| border-collapse: collapse !important; |
| width: 100% !important; |
| } |
| |
| /* En-tête : violet marque, texte blanc, ratio 8.6:1 */ |
| .gradio-container table thead, |
| .gradio-container table thead tr, |
| .gradio-container table thead th, |
| .gradio-container th, |
| .gradio-container .header-cell { |
| background: #5B21B6 !important; |
| color: #FFFFFF !important; |
| font-weight: 700 !important; |
| font-size: 0.92rem !important; |
| text-align: left !important; |
| padding: 0.7rem 0.9rem !important; |
| border: none !important; |
| border-bottom: 2px solid #3B0764 !important; |
| } |
| .gradio-container th *, .gradio-container thead * { |
| color: #FFFFFF !important; |
| background: transparent !important; |
| } |
| |
| /* Cellules : texte sombre sur blanc, ratio 18:1 */ |
| .gradio-container table tbody td, |
| .gradio-container td, |
| .gradio-container .cell-wrap, |
| .gradio-container .cell-wrap span, |
| .gradio-container td span, |
| .gradio-container td div { |
| background: #FFFFFF !important; |
| color: #111827 !important; |
| font-size: 0.92rem !important; |
| padding: 0.6rem 0.9rem !important; |
| border-bottom: 1px solid #ECE8F5 !important; |
| vertical-align: top !important; |
| } |
| |
| /* Zébrage discret : lignes paires légèrement teintées, lisibilité longue liste */ |
| .gradio-container table tbody tr:nth-child(even) td, |
| .gradio-container table tbody tr:nth-child(even) .cell-wrap { |
| background: #F7F5FB !important; |
| } |
| |
| /* Survol et sélection : violet clair, texte inchangé (info pas par couleur seule) */ |
| .gradio-container table tbody tr:hover td, |
| .gradio-container table tbody tr:hover .cell-wrap { |
| background: #EDE9FE !important; |
| cursor: pointer !important; |
| } |
| .gradio-container table tbody tr.selected td, |
| .gradio-container table tbody tr[class*="selected"] td { |
| background: #EDE9FE !important; |
| border-left: 3px solid #5B21B6 !important; |
| } |
| |
| /* Coins, scrollbars et zones vides du composant Dataframe */ |
| .gradio-container .dataframe .empty, |
| .gradio-container .table-wrap .empty, |
| .gradio-container [class*="dataframe"] > div { |
| background: #FFFFFF !important; |
| color: #374151 !important; |
| } |
| |
| /* ═══════════════════════════════════════════════════════════════ |
| 23. MENUS DÉROULANTS (Dropdown) — liste ouverte en clair |
| ═══════════════════════════════════════════════════════════════ */ |
| .gradio-container ul.options, |
| .gradio-container .dropdown-menu, |
| .gradio-container [class*="options"] { |
| background: #FFFFFF !important; |
| color: #111827 !important; |
| border: 1.5px solid #C7BEDD !important; |
| border-radius: 8px !important; |
| box-shadow: 0 6px 18px rgba(76,29,149,0.16) !important; |
| } |
| .gradio-container ul.options li, |
| .gradio-container .item { |
| background: #FFFFFF !important; |
| color: #111827 !important; |
| padding: 0.5rem 0.8rem !important; |
| } |
| .gradio-container ul.options li:hover, |
| .gradio-container ul.options li.selected, |
| .gradio-container .item:hover { |
| background: #EDE9FE !important; |
| color: #3B0764 !important; |
| font-weight: 600 !important; |
| } |
| |
| /* ═══════════════════════════════════════════════════════════════ |
| 24. TYPOGRAPHIE & FINITIONS PRO |
| ═══════════════════════════════════════════════════════════════ */ |
| /* Hiérarchie des titres nette */ |
| .gradio-container h2 { |
| color: #3B0764 !important; |
| font-size: 1.3rem !important; |
| font-weight: 700 !important; |
| letter-spacing: -0.01em !important; |
| margin: 0.6rem 0 0.4rem 0 !important; |
| } |
| .gradio-container h3 { |
| color: #5B21B6 !important; |
| font-size: 1.08rem !important; |
| font-weight: 650 !important; |
| } |
| |
| /* Labels de champs bien lisibles */ |
| .gradio-container label, |
| .gradio-container label span, |
| .gradio-container .label-wrap span { |
| color: #111827 !important; |
| font-weight: 600 !important; |
| font-size: 0.92rem !important; |
| } |
| |
| /* Textes secondaires : jamais en dessous du ratio 4.5:1 */ |
| .gradio-container .info, |
| .gradio-container small, |
| .gradio-container .secondary-text { |
| color: #374151 !important; |
| font-size: 0.85rem !important; |
| } |
| |
| /* Champs de saisie : texte net, placeholder distinct mais conforme */ |
| .gradio-container textarea, |
| .gradio-container input[type="text"], |
| .gradio-container input[type="number"] { |
| background: #FFFFFF !important; |
| color: #111827 !important; |
| border: 1.5px solid #B9ACD6 !important; |
| border-radius: 8px !important; |
| line-height: 1.5 !important; |
| } |
| .gradio-container textarea::placeholder, |
| .gradio-container input::placeholder { |
| color: #6B7280 !important; /* ratio 4.6:1 sur blanc */ |
| opacity: 1 !important; |
| } |
| |
| /* Markdown interne : listes et paragraphes aérés */ |
| .gradio-container .prose p, |
| .gradio-container .prose li { |
| color: #111827 !important; |
| line-height: 1.6 !important; |
| } |
| .gradio-container .prose strong { color: #3B0764 !important; } |
| |
| /* ═══════════════════════════════════════════════════════════════ |
| 25. FILET DE SÉCURITÉ FINAL — élément racine et fond de page |
| La zone sombre pleine largeur vient de l'élément <gradio-app> |
| lui-même et des conteneurs de page hors .gradio-container. |
| On force ici TOUTES les surfaces racines en clair. |
| ═══════════════════════════════════════════════════════════════ */ |
| gradio-app, |
| gradio-app > div, |
| gradio-app > .main, |
| gradio-app .main, |
| gradio-app .app, |
| body > gradio-app, |
| #root, |
| .embed-container, |
| .gradio-container > .main, |
| main, |
| footer, |
| .footer { |
| background: #FAF9FC !important; |
| color: #111827 !important; |
| } |
| |
| /* L'élément racine doit couvrir toute la hauteur pour qu'aucune |
| bande sombre n'apparaisse sous le contenu */ |
| gradio-app { |
| display: block !important; |
| min-height: 100vh !important; |
| } |
| |
| html, body { |
| min-height: 100vh !important; |
| background: #FAF9FC !important; |
| } |
| |
| /* Pied de page Gradio (Built with Gradio, Settings, API) */ |
| footer, footer *, .footer * { |
| background: transparent !important; |
| color: #374151 !important; |
| } |
| footer a { color: #5B21B6 !important; } |
| |
| /* Dernier recours : tout descendant direct du body reste clair */ |
| body > div, body > div > div { |
| background: #FAF9FC !important; |
| } |
| |
| /* ═══════════════════════════════════════════════════════════════ |
| 26. NEUTRALISATION prefers-color-scheme CÔTÉ CSS PUR |
| Double sécurité : même si le script du <head> était bloqué par |
| le navigateur, cette media query réaffirme le clair partout, |
| y compris pour les composants qui lisent la media query CSS |
| plutôt que window.matchMedia en JS. |
| ═══════════════════════════════════════════════════════════════ */ |
| @media (prefers-color-scheme: dark) { |
| html, body, gradio-app, .gradio-container, .dark, |
| .gradio-container table, .gradio-container thead, .gradio-container tbody, |
| .gradio-container .table-wrap, .gradio-container [class*="dataframe"], |
| .gradio-container [class*="table"], footer, main { |
| background: #FAF9FC !important; |
| color: #111827 !important; |
| color-scheme: light !important; |
| } |
| .gradio-container table thead th, .gradio-container th { |
| background: #5B21B6 !important; |
| color: #FFFFFF !important; |
| } |
| .gradio-container table tbody td, .gradio-container td { |
| background: #FFFFFF !important; |
| color: #111827 !important; |
| } |
| } |
| """ |
|
|
| |
| |
| |
| PLOT_COLORS = ["#5B21B6", "#0EA5E9", "#059669", "#D97706", "#DC2626", "#7C3AED", "#0284C7"] |
| SENTIMENT_COLORS = {"Positif": "#059669", "Mitige": "#D97706", "Negatif": "#DC2626"} |
| PLOTLY_LAYOUT = dict( |
| template="plotly_white", |
| font=dict(family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif", size=14, color="#111827"), |
| paper_bgcolor="#FFFFFF", |
| plot_bgcolor="#FFFFFF", |
| margin=dict(t=80, r=30, b=80, l=80), |
| hovermode="closest", |
| legend=dict(bgcolor="#FFFFFF", bordercolor="#D1D5DB", borderwidth=1), |
| height=420, |
| ) |
|
|
| 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","deco","decor"], |
| "Attente": ["attendu","attente","lent","rapide","efficace","40 minutes","semaine"], |
| "Hygiene": ["cheveu","sale","proprete","hygiene","mouche"], |
| "Horaires": ["ferme","mardi","horaires","prevenir","ouvert"], |
| "Famille": ["famille","enfant","nuggets","dimanche","parents","poussette"], |
| } |
|
|
| PLATEFORMES = ["Toutes les plateformes", "Google", "TripAdvisor", "TheFork", "Instagram", "Facebook", "Autre"] |
| PLATEFORMES_FILTRE = PLATEFORMES |
|
|
| EXAMPLE_REVIEWS = [ |
| ["Excellent repas en famille. La sole normande était parfaite et le service très attentionné. Cadre chaleureux.", 5, "Le Normand - Caen", "Google"], |
| ["Service lent, plat froid et addition trop élevée. Très déçu par cette expérience.", 1, "Le Normand - Cabourg", "TripAdvisor"], |
| ["Correct pour un déjeuner rapide. La carte manque un peu de renouvellement.", 3, "Le Normand - Bayeux", "Google"], |
| ["Cadre superbe en bord de mer ! Le camembert rôti est une merveille. On reviendra 🙌", 5, "Le Normand - Cabourg", "Instagram"], |
| ] |
|
|
|
|
| @dataclass |
| class CheckResult: |
| name: str |
| ok: bool |
| detail: str |
|
|
| @property |
| def status(self) -> str: |
| return "✅ OK" if self.ok else "❌ ÉCHEC" |
|
|
|
|
| def strip_accents(value: object) -> str: |
| text = "" if value is None else str(value) |
| return unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii").lower() |
|
|
|
|
| def mask_personal_data(text: str) -> str: |
| if not text: |
| return "" |
| masked = re.sub(r"\b[\w.+-]+@[\w.-]+\.[a-zA-Z]{2,}\b", "[email masqué]", text) |
| masked = re.sub(r"(?:(?:\+33|0)[1-9](?:[\s.-]?\d{2}){4})", "[téléphone masqué]", masked) |
| return masked |
|
|
|
|
| def classify_sentiment(note: float | int | str) -> str: |
| try: |
| v = float(note) |
| except Exception: |
| return "Non classé" |
| if v >= 4: |
| return "Positif" |
| if v <= 2: |
| return "Negatif" |
| return "Mitige" |
|
|
|
|
| def detect_themes(review_text: str) -> list[str]: |
| normalized = strip_accents(review_text) |
| themes = [t for t, kws in THEME_KEYWORDS.items() if any(k in normalized for k in kws)] |
| return themes or ["Général"] |
|
|
|
|
| def standardize_columns(df: pd.DataFrame) -> pd.DataFrame: |
| copy = df.copy() |
| rename_map: dict[str, str] = {} |
| for col in copy.columns: |
| key = strip_accents(col).strip().replace("-", "_") |
| key = re.sub(r"\s+", " ", key) |
| nk = key.replace(" ", "_") if key not in COLUMN_ALIASES else key |
| rename_map[col] = COLUMN_ALIASES.get(key) or COLUMN_ALIASES.get(nk) or key |
| return copy.rename(columns=rename_map) |
|
|
|
|
| def coerce_file_path(file_input: object | None) -> Path: |
| if file_input is None: |
| return DEFAULT_CSV_PATH |
| if isinstance(file_input, (Path, str)): |
| return Path(file_input) |
| for attr in ("name", "path"): |
| v = getattr(file_input, attr, None) |
| if v: |
| return Path(v) |
| raise ValueError("Fichier CSV non reconnu.") |
|
|
|
|
| def load_reviews(file_input: object | None = None) -> pd.DataFrame: |
| p = coerce_file_path(file_input) |
| if not p.exists(): |
| raise FileNotFoundError(f"Fichier introuvable : {p}") |
| return standardize_columns(pd.read_csv(p)) |
|
|
|
|
| def prepare_reviews(df: pd.DataFrame) -> pd.DataFrame: |
| 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: |
| raise ValueError(f"Colonnes manquantes : {', '.join(missing)}. Attendues : date, restaurant, note, avis.") |
| 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 précisée" |
| prepared["plateforme"] = prepared["plateforme"].fillna("Non précisée").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 après contrôle.") |
| prepared["sentiment"] = prepared["note"].apply(classify_sentiment) |
| prepared["themes"] = prepared["avis"].apply(detect_themes) |
| prepared["theme_principal"] = prepared["themes"].apply(lambda v: v[0] if v else "Général") |
| 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: |
| return sorted(load_and_prepare(DEFAULT_CSV_PATH)["restaurant"].dropna().unique().tolist()) |
| except Exception: |
| return ["Le Normand - Bayeux", "Le Normand - Caen", "Le Normand - Cabourg"] |
|
|
|
|
| def truncate_text(text: str, max_len: int = 90) -> str: |
| text = (text or "").strip().replace("\n", " ") |
| if len(text) <= max_len: |
| return text |
| return text[: max_len - 1].rstrip() + "…" |
|
|
|
|
| def build_review_table(df: pd.DataFrame) -> pd.DataFrame: |
| """Construit le tableau d'avis cliquables affiché en bas de l'onglet Répondre. |
| |
| Les colonnes correspondent à ce que l'utilisateur doit voir d'un coup d'œil |
| pour choisir un avis à traiter : le texte, la note et la plateforme. |
| Le restaurant n'apparaît pas ici car il reste piloté par son propre menu |
| dans le formulaire ; cela évite une colonne redondante avec le contexte |
| déjà visible juste au-dessus. |
| """ |
| table = pd.DataFrame({ |
| "Avis client": df["avis"].map(truncate_text), |
| "Note (sur 5)": df["note"].astype(int), |
| "Plateforme": df["plateforme"], |
| }) |
| return table |
|
|
|
|
| def load_reviews_for_picker(file_input: object | None = None): |
| """Charge un CSV (ou le CSV exemple par défaut) pour le tableau d'avis cliquables. |
| |
| Retourne le DataFrame préparé (état caché), le tableau affiché et un message |
| de statut. Si aucun fichier n'est fourni, recharge automatiquement le CSV |
| exemple livré avec l'application — il n'y a donc jamais d'écran vide. |
| """ |
| try: |
| df = load_and_prepare(file_input if file_input is not None else DEFAULT_CSV_PATH) |
| except Exception as exc: |
| empty = pd.DataFrame({"Avis client": [], "Note (sur 5)": [], "Plateforme": []}) |
| return None, empty, f"### Erreur de chargement\n\n{exc}\n\nColonnes attendues : date, restaurant, note, plateforme (optionnelle), avis." |
| table = build_review_table(df) |
| status = f"### ✅ {len(df)} avis disponibles\n\nCliquez sur une ligne du tableau ci-dessous : le formulaire se remplit automatiquement." |
| return df, table, status |
|
|
|
|
| def apply_selected_review(df: pd.DataFrame | None, evt: gr.SelectData): |
| """Pré-remplit le formulaire de réponse à partir de la ligne cliquée dans le tableau.""" |
| if df is None or evt is None or evt.index is None: |
| return gr.update(), gr.update(), gr.update(), gr.update() |
| row_idx = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index |
| if row_idx is None or row_idx < 0 or row_idx >= len(df): |
| return gr.update(), gr.update(), gr.update(), gr.update() |
| row = df.iloc[row_idx] |
| return row["avis"], int(row["note"]), row["restaurant"], row["plateforme"] |
|
|
|
|
| def format_theme_list(themes: Iterable[str]) -> str: |
| values = [t for t in themes if t and t != "Général"] |
| if not values: |
| return "l'expérience client" |
| if len(values) == 1: |
| return values[0].lower() |
| return ", ".join(t.lower() for t in values[:-1]) + " et " + values[-1].lower() |
|
|
|
|
| def mistral_complete(messages: list[dict], max_tokens: int = 350) -> Optional[str]: |
| 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): |
| content = "\n".join(getattr(i, "text", None) or str(i) for i in content) |
| return str(content).strip() or None |
| except Exception as exc: |
| print(f"[AvisIA] Mistral indisponible, bascule locale : {exc}", file=sys.stderr) |
| return None |
|
|
|
|
| def is_instagram(platform: str) -> bool: |
| return strip_accents(platform or "").strip() == "instagram" |
|
|
|
|
| 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: |
| try: |
| n = float(note) |
| except Exception: |
| n = 3.0 |
| themes = detect_themes(review_text) |
| theme_text = format_theme_list(themes) |
| r = restaurant or "notre restaurant" |
| instagram = is_instagram(platform) |
| if n >= 4: |
| if instagram: |
| return (f"Merci pour ce super retour ! 🙏 Toute l'équipe de {r} est ravie que {theme_text} " |
| f"vous ait plu. On vous attend avec plaisir pour une prochaine escapade normande ! 🍽️✨") |
| return (f"Bonjour, merci beaucoup pour votre avis et votre note de {n:g}/5. " |
| f"Toute l'équipe de {r} est ravie que {theme_text} vous ait plu. " |
| f"Au plaisir de vous accueillir de nouveau très bientôt !") |
| if n <= 2: |
| if instagram: |
| return (f"Merci pour votre retour. Nous sommes sincèrement désolés de cette expérience. " |
| f"Votre remarque sur {theme_text} est transmise à l'équipe de {r}. " |
| f"N'hésitez pas à nous contacter en DM pour en discuter. 🙏") |
| return (f"Bonjour, merci d'avoir pris le temps de partager votre expérience. " |
| f"Nous sommes sincèrement désolés que votre visite à {r} n'ait pas été à la hauteur. " |
| f"Votre remarque sur {theme_text} est transmise à l'équipe pour une action concrète. " |
| f"Nous espérons pouvoir vous offrir une meilleure expérience lors d'une prochaine visite.") |
| if instagram: |
| return (f"Merci pour ce retour ! 😊 On note votre remarque sur {theme_text} " |
| f"pour continuer à progresser à {r}. À bientôt !") |
| return (f"Bonjour, merci pour votre retour sur {r}. " |
| f"Nous prenons en compte votre remarque sur {theme_text} pour continuer à progresser. " |
| f"Nous serons ravis de vous revoir pour une expérience 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: |
| if not review_text or not str(review_text).strip(): |
| return "Collez d'abord un avis client pour générer une réponse." |
| sanitized = mask_personal_data(str(review_text).strip()) |
| r = restaurant or "le restaurant" |
| p = platform or "la plateforme" |
| is_generic_platform = strip_accents(p).strip() == "toutes les plateformes" |
| platform_for_prompt = "une plateforme d'avis non précisée" if is_generic_platform else p |
| if use_ai: |
| insta_hint = ( |
| " La réponse sera publiée sur Instagram : adopte un ton court, chaleureux, avec 1-2 emojis max." |
| if is_instagram(p) else "" |
| ) |
| messages = [ |
| {"role": "system", "content": ( |
| "Tu aides un restaurateur à répondre à un avis client. " |
| "Rédige en français, sans inventer de faits, avec empathie. " |
| "La réponse doit être courte, professionnelle, publiable et relue par un humain. " |
| "Ne promets pas de compensation financière. Ne cite pas de données personnelles." |
| + insta_hint |
| )}, |
| {"role": "user", "content": ( |
| f"Restaurant : {r}\nPlateforme : {platform_for_prompt}\nNote : {note}/5\nTon : {tone}\n" |
| f"Avis (données personnelles masquées) : {sanitized}\n\n" |
| f"Génère une réponse de moins de {MAX_AI_WORDS} mots." |
| )}, |
| ] |
| ai = mistral_complete(messages, max_tokens=320) |
| if ai: |
| return ai |
| return fallback_review_response(sanitized, note, r, p, tone) |
|
|
|
|
| def summarize_restaurants(df: pd.DataFrame) -> pd.DataFrame: |
| p = prepare_reviews(df) |
| s = ( |
| p.groupby("restaurant", as_index=False) |
| .agg( |
| avis=("avis", "count"), |
| note_moyenne=("note", "mean"), |
| notes_negatives=("sentiment", lambda v: int((v == "Negatif").sum())), |
| part_positive=("sentiment", lambda v: round((v == "Positif").mean() * 100, 1)), |
| ) |
| .sort_values("note_moyenne", ascending=False) |
| ) |
| s["note_moyenne"] = s["note_moyenne"].round(2) |
| return s.reset_index(drop=True) |
|
|
|
|
| def summary_markdown(df: pd.DataFrame) -> str: |
| p = prepare_reviews(df) |
| by_r = summarize_restaurants(p) |
| total = len(p) |
| nb_resto = p["restaurant"].nunique() |
| avg = p["note"].mean() |
| neg = int((p["sentiment"] == "Negatif").sum()) |
| pos_pct = (p["sentiment"] == "Positif").mean() * 100 |
| kd = p.dropna(subset=["date"]) |
| date_range = ( |
| f"du {kd['date'].min().date()} au {kd['date'].max().date()}" if not kd.empty else "période non renseignée" |
| ) |
| best = by_r.iloc[0] |
| worst = by_r.iloc[-1] |
| return ( |
| f"### Résumé du tableau de bord\n\n" |
| f"**{total} avis analysés** sur **{nb_resto} restaurants** ({date_range}). \n" |
| f"Note moyenne réseau : **{avg:.2f}/5** · {pos_pct:.1f} % d'avis positifs · {neg} avis négatifs. \n" |
| f"Meilleur score : **{best['restaurant']}** ({best['note_moyenne']:.2f}/5). \n" |
| f"Établissement à surveiller : **{worst['restaurant']}** ({worst['note_moyenne']:.2f}/5). \n\n" |
| "_Les valeurs sont aussi affichées directement sur les graphiques pour une lecture sans couleurs._" |
| ) |
|
|
|
|
| def style_figure(fig: go.Figure, title: str, legend_title: str | None = None) -> go.Figure: |
| layout = dict(PLOTLY_LAYOUT) |
| layout["title"] = {"text": title, "x": 0.02, "font": {"size": 16, "color": "#111827"}} |
| if legend_title: |
| layout["legend_title_text"] = legend_title |
| fig.update_layout(**layout) |
| fig.update_xaxes(title_font_size=14, tickfont_size=13, automargin=True, gridcolor="#F3F4F6") |
| fig.update_yaxes(title_font_size=14, tickfont_size=13, automargin=True, gridcolor="#F3F4F6") |
| return fig |
|
|
|
|
| def empty_figure(message: str = "Aucune donnée à afficher") -> go.Figure: |
| fig = go.Figure() |
| fig.add_annotation(text=message, x=0.5, y=0.5, showarrow=False, |
| font={"size": 16, "color": "#374151"}, xref="paper", yref="paper") |
| fig.update_layout(**PLOTLY_LAYOUT) |
| return fig |
|
|
|
|
| def build_figures(df: pd.DataFrame): |
| p = prepare_reviews(df) |
|
|
| |
| avg = summarize_restaurants(p) |
| fig1 = px.bar( |
| avg, x="restaurant", y="note_moyenne", text="note_moyenne", |
| color="note_moyenne", |
| color_continuous_scale=["#DC2626", "#D97706", "#059669"], |
| range_color=[1, 5], |
| labels={"restaurant": "Restaurant", "note_moyenne": "Note moyenne /5"}, |
| ) |
| fig1.update_traces(texttemplate="%{text:.2f}/5", textposition="outside", cliponaxis=False) |
| fig1.update_yaxes(range=[0, 5.8]) |
| fig1.update_coloraxes(showscale=False) |
| style_figure(fig1, "1. Note moyenne par restaurant") |
|
|
| |
| sc = p.groupby(["restaurant", "sentiment"], as_index=False).size().rename(columns={"size": "avis"}) |
| fig2 = px.bar( |
| sc, x="restaurant", y="avis", color="sentiment", barmode="group", text="avis", |
| color_discrete_map=SENTIMENT_COLORS, |
| labels={"restaurant": "Restaurant", "avis": "Nombre d'avis", "sentiment": "Sentiment"}, |
| ) |
| fig2.update_traces(textposition="outside", cliponaxis=False) |
| style_figure(fig2, "2. Sentiments comparés par restaurant", "Sentiment") |
|
|
| |
| tc = ( |
| p.explode("themes") |
| .groupby(["restaurant", "themes"], as_index=False) |
| .size() |
| .rename(columns={"size": "mentions", "themes": "theme"}) |
| ) |
| fig3 = px.bar( |
| tc, x="restaurant", y="mentions", color="theme", barmode="stack", text="mentions", |
| color_discrete_sequence=PLOT_COLORS, |
| labels={"restaurant": "Restaurant", "mentions": "Mentions", "theme": "Thème"}, |
| ) |
| fig3.update_traces(textposition="inside") |
| style_figure(fig3, "3. Thèmes mentionnés par restaurant", "Thème") |
|
|
| |
| weak = p[p["note"] <= 3].explode("themes") |
| if weak.empty: |
| fig4 = empty_figure("Aucun point faible détecté — tous les avis sont positifs ou mitigés !") |
| else: |
| wp = ( |
| weak.groupby(["restaurant", "themes"], as_index=False) |
| .agg(mentions=("avis", "count"), note_moy=("note", "mean")) |
| .rename(columns={"themes": "theme"}) |
| ) |
| wp["priorité"] = (wp["mentions"] * (6 - wp["note_moy"])).round(2) |
| wp = wp.sort_values("priorité", ascending=True).tail(10) |
| fig4 = px.bar( |
| wp, x="priorité", y="restaurant", color="theme", orientation="h", text="mentions", |
| color_discrete_sequence=PLOT_COLORS, |
| labels={"restaurant": "Restaurant", "priorité": "Score priorité", "theme": "Point faible"}, |
| ) |
| fig4.update_traces(texttemplate="%{text} avis", textposition="outside", cliponaxis=False) |
| style_figure(fig4, "4. Points faibles à traiter en priorité", "Thème") |
|
|
| |
| monthly = p[p["mois"] != "Date inconnue"] |
| if monthly.empty: |
| fig5 = empty_figure("Aucune date valide pour l'évolution mensuelle") |
| else: |
| ms = ( |
| monthly.groupby(["mois", "restaurant"], as_index=False) |
| .agg(note_moyenne=("note", "mean"), avis=("avis", "count")) |
| .sort_values("mois") |
| ) |
| ms["note_moyenne"] = ms["note_moyenne"].round(2) |
| fig5 = px.line( |
| ms, x="mois", y="note_moyenne", color="restaurant", |
| markers=True, text="note_moyenne", |
| color_discrete_sequence=PLOT_COLORS, |
| labels={"mois": "Mois", "note_moyenne": "Note moyenne /5", "restaurant": "Restaurant"}, |
| ) |
| fig5.update_traces(texttemplate="%{text:.2f}", textposition="top center") |
| fig5.update_yaxes(range=[0, 5.8]) |
| style_figure(fig5, "5. Évolution mensuelle de la note moyenne", "Restaurant") |
|
|
| return fig1, fig2, fig3, fig4, fig5 |
|
|
|
|
| def top_negative_theme(p: pd.DataFrame, restaurant: str) -> str: |
| sub = p[(p["restaurant"] == restaurant) & (p["note"] <= 3)].explode("themes") |
| if sub.empty: |
| return "aucun point faible majeur" |
| return str(sub["themes"].value_counts().index[0]).lower() |
|
|
|
|
| def deterministic_team_briefing(df: pd.DataFrame) -> str: |
| p = prepare_reviews(df) |
| s = summarize_restaurants(p) |
| best, worst = s.iloc[0], s.iloc[-1] |
| lines = [ |
| "### Bilan d'équipe actionnable\n", |
| f"**Priorité réseau :** maintenir les pratiques de **{best['restaurant']}** " |
| f"({best['note_moyenne']:.2f}/5) et accompagner **{worst['restaurant']}** " |
| f"({worst['note_moyenne']:.2f}/5).\n", |
| "**Lecture par restaurant :**", |
| ] |
| for _, row in s.iterrows(): |
| wt = top_negative_theme(p, row["restaurant"]) |
| lines.append( |
| f"- **{row['restaurant']}** : {row['avis']} avis, " |
| f"{row['note_moyenne']:.2f}/5, {row['notes_negatives']} avis négatifs. " |
| f"Point à suivre : {wt}." |
| ) |
| lines += [ |
| "\n**Actions conseillées pour le briefing du lundi :**", |
| "1. Relire les avis négatifs avant de publier les réponses.", |
| "2. Choisir une action simple par restaurant selon le thème dominant.", |
| "3. Mesurer l'effet le mois suivant avec la courbe d'évolution.\n", |
| "_L'IA propose une aide à la décision ; le restaurateur relit, ajuste et décide._", |
| ] |
| return "\n".join(lines) |
|
|
|
|
| def generate_team_briefing(df: pd.DataFrame, use_ai: bool = True) -> str: |
| p = prepare_reviews(df) |
| if use_ai: |
| s = summarize_restaurants(p) |
| themes = ( |
| p.explode("themes").groupby(["restaurant", "themes"], as_index=False) |
| .size().rename(columns={"size": "mentions"}) |
| .sort_values(["restaurant", "mentions"], ascending=[True, False]) |
| ) |
| payload = { |
| "restaurants": s.to_dict(orient="records"), |
| "themes": themes.head(30).to_dict(orient="records"), |
| } |
| msgs = [ |
| {"role": "system", "content": "Tu aides un restaurateur multi-sites à préparer un briefing d'équipe. Utilise uniquement les données agrégées fournies. Produis un bilan court, actionnable et prudent."}, |
| {"role": "user", "content": f"Données : {payload}\nRédige un bilan en français avec 3 actions prioritaires."}, |
| ] |
| ai = mistral_complete(msgs, max_tokens=450) |
| if ai: |
| return ai |
| return deterministic_team_briefing(p) |
|
|
|
|
| def filter_by_platform(df: pd.DataFrame, platform: str | None) -> pd.DataFrame: |
| if not platform or platform == "Toutes les plateformes": |
| return df |
| return df[df["plateforme"].str.casefold() == platform.casefold()].reset_index(drop=True) |
|
|
|
|
| def build_dashboard(file_input: object | None = None, platform: str | None = None, use_ai: bool = True): |
| try: |
| df = load_and_prepare(file_input) |
| df = filter_by_platform(df, platform) |
| if df.empty: |
| empty = empty_figure(f"Aucun avis pour la plateforme « {platform} ».") |
| return ( |
| f"### Aucun résultat\n\nAucun avis trouvé pour la plateforme **{platform}**. Choisissez « Toutes les plateformes » ou une autre plateforme.", |
| empty, empty, empty, empty, empty, |
| "Bilan indisponible : aucun avis ne correspond au filtre sélectionné.", |
| ) |
| figs = build_figures(df) |
| return (summary_markdown(df), *figs, generate_team_briefing(df, use_ai=use_ai)) |
| except Exception as exc: |
| empty = empty_figure(str(exc)) |
| return ( |
| f"### Erreur d'analyse\n\n{exc}\n\nColonnes attendues : date, restaurant, note, plateforme (optionnelle), avis.", |
| empty, empty, empty, empty, empty, |
| "Bilan indisponible tant que le CSV n'est pas valide.", |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def add_check(results, name, ok, detail): |
| results.append(CheckResult(name=name, ok=bool(ok), detail=detail)) |
|
|
|
|
| def run_quality_checks() -> list[CheckResult]: |
| results: list[CheckResult] = [] |
| raw = pd.DataFrame() |
| prepared = pd.DataFrame() |
|
|
| 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 ; colonnes : {', '.join(raw.columns)}") |
| except Exception as exc: |
| add_check(results, "1. CSV exemple lisible et colonnes obligatoires", False, str(exc)) |
|
|
| try: |
| prepared = prepare_reviews(raw) |
| add_check(results, "2. Jeu exemple cohérent (≥ 40 avis, 5 restaurants)", |
| len(prepared) >= 40 and prepared["restaurant"].nunique() == 5, |
| f"{len(prepared)} avis ; {prepared['restaurant'].nunique()} restaurants") |
| except Exception as exc: |
| add_check(results, "2. Jeu exemple cohérent (≥ 40 avis, 5 restaurants)", False, str(exc)) |
|
|
| try: |
| sents = set(prepared["sentiment"].dropna().unique()) |
| add_check(results, "3. Classification sentiments (Positif/Mitige/Negatif)", |
| {"Positif", "Mitige", "Negatif"}.issubset(sents), |
| f"Sentiments : {', '.join(sorted(sents))}") |
| 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. Détection des thèmes (≥ 5 thèmes distincts)", |
| len(themes) >= 5, f"Thèmes : {', '.join(sorted(themes))}") |
| except Exception as exc: |
| add_check(results, "4. Détection des thèmes", False, str(exc)) |
|
|
| try: |
| reply = generate_review_response("Service lent et plat froid, très déçu.", 1, |
| "Le Normand - Cabourg", "Google", use_ai=False) |
| add_check(results, "5. Réponse locale sans clé API (Google)", |
| len(reply) > 80 and "sol" in strip_accents(reply), reply[:180]) |
| except Exception as exc: |
| add_check(results, "5. Réponse locale sans clé API (Google)", False, str(exc)) |
|
|
| try: |
| insta_reply = generate_review_response("Super terrasse en bord de mer !", 5, |
| "Le Normand - Cabourg", "Instagram", use_ai=False) |
| add_check(results, "6. Réponse Instagram avec ton adapté", |
| len(insta_reply) > 30 and ("🙏" in insta_reply or "!" in insta_reply), |
| insta_reply[:180]) |
| except Exception as exc: |
| add_check(results, "6. Réponse Instagram avec ton adapté", False, str(exc)) |
|
|
| try: |
| figs = build_figures(prepared) |
| add_check(results, "7. Cinq graphiques Plotly générés", |
| len(figs) == 5 and all(isinstance(f, go.Figure) for f in figs), |
| f"{len(figs)} figures générées") |
| except Exception as exc: |
| add_check(results, "7. Cinq graphiques Plotly générés", False, str(exc)) |
|
|
| try: |
| dashboard = build_dashboard(DEFAULT_CSV_PATH, use_ai=False) |
| add_check(results, "8. Tableau de bord complet (7 sorties)", |
| len(dashboard) == 7 and "Bilan" in dashboard[-1], |
| "Résumé, 5 graphiques et bilan équipe générés.") |
| except Exception as exc: |
| add_check(results, "8. Tableau de bord complet", False, str(exc)) |
|
|
| return results |
|
|
|
|
| def run_tests_for_ui(): |
| results = run_quality_checks() |
| passed = sum(r.ok for r in results) |
| total = len(results) |
| summary = ( |
| f"### Résultat des tests\n\n" |
| f"**{passed}/{total} contrôles OK.** " |
| f"{'✅ Tout est opérationnel.' if passed == total else '⚠️ Vérifier les échecs ci-dessus.'}\n\n" |
| f"_Exécutables aussi en local : `python test_app.py`_" |
| ) |
| return [[r.name, r.status, r.detail] for r in results], summary |
|
|
|
|
| def print_startup_checks(results: list[CheckResult]) -> None: |
| print("\n[AvisIA] ── Tests automatiques au démarrage ──") |
| for r in results: |
| print(f"[AvisIA] {'OK' if r.ok else 'ECHEC'} – {r.name} : {r.detail}") |
| passed = sum(r.ok for r in results) |
| print(f"[AvisIA] Synthèse : {passed}/{len(results)} tests OK\n") |
|
|
|
|
| def method_markdown() -> str: |
| return """ |
| ## Méthode, conformité et limites |
| |
| ### Données & RGPD |
| - **Stateless** : l'application n'enregistre aucune donnée entre les sessions. |
| - **Minimisation** : seules les colonnes utiles (date, restaurant, note, avis, plateforme) sont traitées. |
| - **Masquage** : emails et téléphones détectés dans les avis sont remplacés avant tout appel IA. |
| - La clé `MISTRAL_API_KEY` est stockée dans **Settings → Variables and secrets** du Space, jamais dans le code. |
| |
| ### AI Act & usage responsable |
| - Risque **limité** : aide à la rédaction et à la décision, sans décision automatique critique. |
| - **Transparence** : l'interface rappelle à chaque étape que l'IA propose et que le restaurateur valide. |
| - **Human-in-the-loop** : aucune réponse n'est publiée automatiquement. |
| |
| ### Instagram & plateformes |
| - Instagram est intégré avec un ton adapté : court, chaleureux, 1-2 emojis. |
| - Aucune connexion API Instagram n'est réalisée dans ce prototype. |
| |
| ### Accessibilité RGAA (thème clair) |
| - Contrastes RGAA AA sur tous les textes (ratio minimum 4.5:1, jusqu'à 18:1). |
| - Focus clavier visible sur tous les éléments interactifs. |
| - Valeurs affichées directement sur les graphiques (pas uniquement la couleur). |
| - Résumé textuel du tableau de bord en complément des visuels. |
| - Police minimale 16 px, labels explicites sur tous les champs. |
| |
| ### Qualité & tests |
| - 8 contrôles qualité automatiques au démarrage (dont test Instagram). |
| - Rejouer les contrôles à tout moment via l'onglet **Tests**. |
| - Suite de tests locaux dans `test_app.py` avant tout déploiement. |
| |
| ### Limites connues |
| - La détection de thèmes est volontairement simple et auditable (mots-clés). |
| - Les réponses IA doivent toujours être relues avant publication, surtout pour les avis sensibles. |
| - Un audit RGAA complet par prestataire reste nécessaire avant production. |
| """.strip() |
|
|
|
|
| |
| |
| |
|
|
| def build_dashboard_default(): |
| """Wrapper sans argument pour demo.load. Charge le CSV exemple, toutes plateformes.""" |
| return build_dashboard(None, platform=None, use_ai=True) |
|
|
|
|
| |
| |
| |
| |
| |
| HEAD_FORCE_CLAIR = """ |
| <script> |
| (function() { |
| try { |
| // Empêche Gradio de lire la préférence sombre du système : |
| // on intercepte matchMedia AVANT que le moindre composant |
| // (y compris les tableaux) ne l'interroge. |
| const originalMatchMedia = window.matchMedia; |
| window.matchMedia = function(query) { |
| if (query && query.includes('prefers-color-scheme: dark')) { |
| return { matches: false, media: query, addListener: function(){}, removeListener: function(){}, addEventListener: function(){}, removeEventListener: function(){} }; |
| } |
| return originalMatchMedia.call(window, query); |
| }; |
| document.documentElement.style.colorScheme = 'light'; |
| document.documentElement.classList.remove('dark'); |
| localStorage.setItem('theme', 'light'); |
| } catch (e) {} |
| })(); |
| </script> |
| """ |
|
|
|
|
| def create_interface() -> gr.Blocks: |
| restaurants = get_default_restaurants() |
|
|
| with gr.Blocks(title="Avis'IA Resto") as demo: |
|
|
| gr.Markdown(""" |
| <div class="app-header"> |
| <h1>🍽️ Avis'IA Resto</h1> |
| <p>Pilotez vos avis clients multi-restaurants — répondez, comparez, décidez.</p> |
| </div> |
| """) |
|
|
| with gr.Tabs(): |
|
|
| |
| with gr.Tab("1. Répondre"): |
| gr.Markdown("## Générer une réponse prête à 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 concerné", |
| allow_custom_value=True, |
| ) |
| platform_input = gr.Dropdown( |
| choices=PLATEFORMES, |
| value="Toutes les plateformes", |
| label="Plateforme de l'avis", |
| 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 attentionné", |
| "Court et direct", |
| ], |
| value="Professionnel et chaleureux", |
| label="Ton souhaité", |
| ) |
| review_input = gr.Textbox( |
| label="Avis client", |
| lines=7, |
| placeholder="Collez ici l'avis Google, TripAdvisor, Instagram…", |
| ) |
| generate_btn = gr.Button("Générer la réponse", variant="primary") |
| with gr.Column(scale=1): |
| response_output = gr.Textbox( |
| label="Réponse proposée par l'IA (ou mode local)", |
| lines=12, |
| ) |
| gr.Markdown(""" |
| <div class="warn-box"> |
| ⚠️ <strong>Human-in-the-loop :</strong> l'IA propose, vous relisez, vous publiez. |
| Ne publiez jamais automatiquement une réponse sans relecture humaine. |
| </div> |
| <div class="info-box" style="margin-top:0.75rem;"> |
| 💡 <strong>Instagram :</strong> sélectionnez la plateforme Instagram pour obtenir une réponse courte avec emojis, adaptée au ton du réseau. |
| </div> |
| """) |
|
|
| gr.Markdown("### Choisir un avis dans le jeu de données") |
| gr.Markdown(""" |
| <div class="info-box"> |
| Le tableau ci-dessous liste les avis du CSV exemple (50 avis, année 2026). Chargez votre propre |
| fichier pour le remplacer. Cliquez sur une ligne : le formulaire ci-dessus se remplit automatiquement. |
| </div> |
| """) |
| picker_csv_input = gr.File(label="Charger un autre CSV d'avis (optionnel)", file_types=[".csv"]) |
| picker_status = gr.Markdown(elem_classes="picker-status") |
| review_table = gr.Dataframe( |
| headers=["Avis client", "Note (sur 5)", "Plateforme"], |
| datatype=["str", "number", "str"], |
| interactive=False, |
| wrap=True, |
| label="Avis cliquables", |
| ) |
| picker_state = gr.State(None) |
|
|
| |
| demo.load( |
| fn=load_reviews_for_picker, |
| inputs=None, |
| outputs=[picker_state, review_table, picker_status], |
| ) |
| |
| picker_csv_input.change( |
| fn=load_reviews_for_picker, |
| inputs=[picker_csv_input], |
| outputs=[picker_state, review_table, picker_status], |
| ) |
| |
| review_table.select( |
| fn=apply_selected_review, |
| inputs=[picker_state], |
| outputs=[review_input, note_input, restaurant_input, platform_input], |
| ) |
|
|
| generate_btn.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") |
| gr.Markdown(""" |
| <div class="info-box"> |
| 📂 Chargez votre fichier CSV (colonnes : <strong>date, restaurant, note, avis</strong> — plateforme optionnelle) |
| ou cliquez sur <strong>« Utiliser le CSV exemple »</strong> pour une démonstration immédiate (220 avis, 5 restaurants, 5 plateformes). |
| Utilisez le filtre plateforme pour vous concentrer sur une seule source, ou gardez <strong>« Toutes les plateformes »</strong> pour la vision globale. |
| </div> |
| """) |
| with gr.Row(): |
| csv_input = gr.File(label="Fichier CSV d'avis", file_types=[".csv"], scale=2) |
| platform_filter = gr.Dropdown( |
| choices=PLATEFORMES_FILTRE, |
| value="Toutes les plateformes", |
| label="Filtrer par plateforme", |
| scale=1, |
| ) |
| with gr.Row(): |
| analyze_btn = gr.Button("Analyser le CSV chargé", variant="primary") |
| example_btn = gr.Button("Utiliser le CSV exemple", variant="secondary") |
|
|
| summary_output = gr.Markdown() |
|
|
| |
| with gr.Row(): |
| fig_1 = gr.Plot(label="Note moyenne par restaurant") |
| fig_2 = gr.Plot(label="Sentiments comparés") |
| with gr.Row(): |
| fig_3 = gr.Plot(label="Thèmes mentionnés") |
| fig_4 = gr.Plot(label="Points faibles") |
| with gr.Row(): |
| fig_5 = gr.Plot(label="Évolution mensuelle") |
|
|
| briefing_output = gr.Markdown() |
|
|
| dashboard_outputs = [summary_output, fig_1, fig_2, fig_3, fig_4, fig_5, briefing_output] |
|
|
| analyze_btn.click( |
| fn=build_dashboard, |
| inputs=[csv_input, platform_filter], |
| outputs=dashboard_outputs, |
| ) |
| example_btn.click( |
| fn=build_dashboard, |
| inputs=[gr.State(None), platform_filter], |
| outputs=dashboard_outputs, |
| ) |
| |
| platform_filter.change( |
| fn=build_dashboard, |
| inputs=[csv_input, platform_filter], |
| outputs=dashboard_outputs, |
| ) |
| |
| demo.load( |
| fn=build_dashboard_default, |
| inputs=None, |
| outputs=dashboard_outputs, |
| ) |
|
|
| |
| with gr.Tab("3. Tests"): |
| gr.Markdown("## Contrôles qualité en direct") |
| gr.Markdown(""" |
| <div class="info-box"> |
| 🔬 Ces 8 contrôles vérifient que l'application est opérationnelle : CSV, sentiments, thèmes, |
| réponse locale, réponse Instagram, graphiques et tableau de bord. |
| Ils se lancent aussi au démarrage (visibles dans les logs Hugging Face). |
| </div> |
| """) |
| test_btn = gr.Button("Lancer les 8 vérifications", variant="primary") |
| tests_table = gr.Dataframe( |
| headers=["Contrôle", "Statut", "Détail"], |
| datatype=["str", "str", "str"], |
| label="Résultats des contrôles qualité", |
| interactive=False, |
| wrap=True, |
| ) |
| tests_summary = gr.Markdown() |
| test_btn.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. Méthode"): |
| 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: |
| demo.launch(css=CUSTOM_CSS, head=HEAD_FORCE_CLAIR) |
| except Exception: |
| traceback.print_exc() |
| raise |
|
|