adrescop commited on
Commit
e7194cd
·
verified ·
1 Parent(s): 3d35cce

Upload 16 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/carte_exemple.png filter=lfs diff=lfs merge=lfs -text
PROMPTS.md ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Utilisation de l'IA générative
2
+
3
+ **Outil :** Claude (Anthropic), modèle `claude-opus-5-5`, dans l'application Claude (mode agent avec exécution de code).
4
+ **Rôle :** lecture du dossier PFE, conception de l'architecture, écriture du code, du jeu de données, des tests et de la documentation.
5
+ Le code a été exécuté et testé dans l'environnement de l'agent (tests dans `tests/`).
6
+
7
+ ## Prompts utilisés (dans l'ordre)
8
+ 1. *« J'ai un projet à réaliser, tu peux m'aider à le faire : le projet consiste à créer une application qui utilise l'IA
9
+ pour résoudre un problème actuel, puis à la présenter en vidéo. [consignes complètes du projet : outils publics
10
+ Hugging Face / GitHub / Kaggle, Python, aller au-delà du cours, reproductible, citer les prompts ; à rendre : code,
11
+ données, guide d'utilisation, bibliothèques et sources] »*
12
+ 2. *« La partie vidéo c'est Manon qui la fait, moi je veux juste faire l'application. »*
13
+ 3. *« Voilà, utilise ça pour faire l'appli, tout est dans le dossier, fais l'appli dedans. »* + pièce jointe :
14
+ `Dossier_PFE_Manon_Thomas_M2_ECV_Paris.pdf`
15
+
16
+ ## Ce que l'IA a produit à partir de ces prompts
17
+ | Fichier | Produit par l'IA | Vérification humaine à faire / faite |
18
+ |---|---|---|
19
+ | `grapp/engine.py`, `grapp/carte.py`, `app.py` | Code complet | Relire, lancer, tester la démo |
20
+ | `data/vins.csv` (via `scripts/build_dataset.py`) | Profils d'appellation (échelles 1-5, arômes, accords, prix indicatifs) | Relire les profils avec les sources de SOURCES.md |
21
+ | `data/dico.csv` | Définitions en ton Gen Z | Relire |
22
+ | `tests/`, README, SOURCES, ce fichier | Oui | — |
23
+
24
+ ## Choix de l'IA à justifier à l'oral
25
+ - Recommandation **hybride et explicable** plutôt qu'un chatbot : réponses rapides, gratuites, hors ligne, sans hallucination de vins inexistants.
26
+ - OCR + correspondance floue pour ne recommander **que des vins présents sur la carte**.
27
+ - Modèle Hugging Face léger et multilingue, avec repli TF-IDF pour garantir la reproductibilité.
28
+
29
+ *(Ajoutez ici les prompts que vous utiliserez ensuite, par ex. avec ChatGPT ou Copilot pour modifier le code.)*
README.md CHANGED
@@ -1,10 +1,123 @@
1
  ---
2
- title: Grapp
3
- emoji: 🚀
4
- colorFrom: purple
5
- colorTo: blue
6
- sdk: static
 
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: GRAPP
3
+ emoji: 🍇
4
+ colorFrom: pink
5
+ colorTo: purple
6
+ sdk: streamlit
7
+ app_file: app.py
8
  pinned: false
9
+ license: mit
10
+ short_description: Choisir son vin sans se prendre la grappe
11
  ---
12
 
13
+ # 🍇 GRAPP — L'app pour choisir ton vin sans te prendre la grappe
14
+
15
+ > Même si t'y connais rien, GRAPP t'aide à trouver LE vin que tu kiffes, super facilement.
16
+
17
+ **Problème.** En 60 ans, la consommation de vin des Français a chuté de 70 %. Les jeunes trouvent le vin élitiste,
18
+ compliqué et intimidant (« c'est cher », « je préfère la bière », « au bar je commande une blonde, c'est plus facile »).
19
+ Devant une carte des vins de resto, ils ne savent pas quoi choisir. Le vin pèse pourtant 15 % de la production agricole
20
+ française en valeur, et 1 exploitation agricole sur 5 a une activité viticole.
21
+
22
+ **Solution.** GRAPP (concept et direction artistique : projet de fin d'études de Manon Thomas, M2 DA, ECV Paris 2025)
23
+ transforme la carte des vins en conseil personnalisé, sans jargon :
24
+
25
+ | Module | Ce que ça fait | IA utilisée |
26
+ |---|---|---|
27
+ | 🥂 **Tchin Tchin !** | 1) photo de la carte du resto → GRAPP repère les vins proposés et leurs prix ; 2) quiz en 7 questions ; 3) top 3 avec un **% de match** et le *pourquoi* | OCR Tesseract + correspondance floue ; recommandation hybride (profil + embeddings de phrases Hugging Face) |
28
+ | 💖 **Mes p'tits favoris** | Garde tes vins pépites et te propose des vins du même style | Similarité d'embeddings |
29
+ | 💡 **Dico Vino** | Pose ta question comme tu veux (« pourquoi ma bouche gratte ? ») → explication cool du mot (ici : tanins) | Recherche sémantique |
30
+
31
+ ### Ce qui va au-delà du cours
32
+ - **Pipeline multimodal** : image → texte (OCR) → entités (vins) → recommandation.
33
+ - **Correspondance floue** robuste aux erreurs d'OCR (« Cétes du Rhone » → Côtes du Rhône, « ler Cru » → 1er Cru).
34
+ - **Recommandation hybride explicable** : score pondéré entre critères du quiz et similarité sémantique d'une phrase libre,
35
+ avec les raisons affichées à l'utilisateur.
36
+ - **Modèle Hugging Face multilingue** (`paraphrase-multilingual-MiniLM-L12-v2`) avec **repli automatique** sur TF-IDF
37
+ hors ligne : l'app tourne toujours.
38
+
39
+ ---
40
+
41
+ ## 📘 Guide d'utilisation
42
+
43
+ ### 1. Installer (une seule fois)
44
+ Il faut **Python 3.10+**.
45
+
46
+ ```bash
47
+ cd GRAPP
48
+ python -m venv .venv
49
+ source .venv/bin/activate # Windows : .venv\Scripts\activate
50
+ pip install -r requirements.txt
51
+ ```
52
+
53
+ Pour lire les photos de cartes, installer aussi le moteur **Tesseract** :
54
+ - macOS : `brew install tesseract tesseract-lang`
55
+ - Windows : installeur sur https://github.com/UB-Mannheim/tesseract/wiki
56
+ - Linux : `sudo apt install tesseract-ocr tesseract-ocr-fra`
57
+
58
+ > Sans Tesseract, tout marche sauf la lecture de photo : on colle alors le texte de la carte.
59
+ > Au premier lancement, le modèle Hugging Face (~470 Mo) se télécharge automatiquement.
60
+
61
+ ### 2. Lancer
62
+ ```bash
63
+ streamlit run app.py
64
+ ```
65
+ L'app s'ouvre dans le navigateur (http://localhost:8501).
66
+
67
+ ### 3. Utiliser
68
+ 1. **Tchin Tchin !** → choisis *Photo*, *Carte d'exemple (démo)* ou *Coller le texte*, puis **Lire la carte**
69
+ (ou « Pas de carte, on passe au quiz »).
70
+ 2. Réponds aux **7 questions** (plan du jour, verre ou bouteille, couleur, bulles, sucre, corps, goûts, budget, envie en une phrase).
71
+ 3. GRAPP affiche ton **top 3** avec le % de match. ❤️ pour garder un vin, 🔁 pour des vins du même style.
72
+ 4. **Mes p'tits favoris** : tes vins enregistrés (dans `data/favoris.json`).
73
+ 5. **Dico Vino** : tape un mot ou une question.
74
+
75
+ Scénario de démo conseillé pour la vidéo : *Carte d'exemple* → Apéro entre potes → bouteille → Rouge → Sans bulles →
76
+ Sec → Léger → Fruité, 30 € → « un truc frais avec de la charcuterie » ⇒ Chinon / Beaujolais-Villages en tête.
77
+
78
+ ### 4. Vérifier que tout marche
79
+ ```bash
80
+ python tests/test_grapp.py # ou : python -m pytest -q
81
+ ```
82
+
83
+ ### 5. (Optionnel) Ajouter de vraies bouteilles notées — Kaggle
84
+ Télécharger `winemag-data-130k-v2.csv` depuis https://www.kaggle.com/datasets/zynicide/wine-reviews dans `data/`, puis :
85
+ ```bash
86
+ python scripts/enrichir_kaggle.py
87
+ ```
88
+ Chaque vin recommandé affiche alors les bouteilles réelles les mieux notées de son appellation.
89
+
90
+ ### 6. Mettre l'app en ligne (lien public)
91
+ **Hugging Face Spaces (gratuit) :**
92
+ 1. Crée un compte sur https://huggingface.co puis *New Space* → nom `grapp`, SDK **Streamlit**, matériel *CPU basic (free)*.
93
+ 2. Onglet *Files* → *Upload files* : glisse **tout le contenu** du dossier GRAPP (dossiers compris), puis *Commit*.
94
+ 3. Attends 3 à 5 minutes (construction). Ton lien : `https://huggingface.co/spaces/<ton-pseudo>/grapp`.
95
+
96
+ Le bloc `---` en haut de ce README et le fichier `packages.txt` (qui installe Tesseract) servent à Hugging Face.
97
+
98
+ **Alternative, Streamlit Community Cloud :** mets le dossier sur GitHub, puis https://share.streamlit.io → *Create app* → choisis le dépôt et `app.py`.
99
+
100
+ > En ligne, les favoris sont partagés par tous les visiteurs et remis à zéro à chaque redémarrage.
101
+
102
+ ---
103
+
104
+ ## 🗂️ Contenu du projet
105
+ ```
106
+ app.py Interface Streamlit (charte graphique du dossier PFE)
107
+ grapp/engine.py Moteur de recommandation, embeddings, Dico Vino
108
+ grapp/carte.py OCR de la carte + correspondance floue + prix
109
+ data/vins.csv 75 profils de vins par appellation (couleur, bulles, sucre, corps, acidité, tanins, arômes, accords, prix)
110
+ data/dico.csv 34 définitions du Dico Vino
111
+ assets/carte_exemple.png Carte des vins fictive pour la démo
112
+ scripts/build_dataset.py Régénère data/vins.csv
113
+ scripts/generer_carte_exemple.py Régénère la carte d'exemple
114
+ scripts/enrichir_kaggle.py Enrichissement optionnel avec Wine Reviews (Kaggle)
115
+ tests/test_grapp.py Tests automatiques
116
+ SOURCES.md Bibliothèques, modèles, données et sources citées
117
+ PROMPTS.md Où et comment l'IA générative a été utilisée, avec les prompts
118
+ ```
119
+
120
+ ## Limites
121
+ - La base contient des **profils typiques d'appellation** (75), pas chaque domaine : deux vins d'une même appellation peuvent différer.
122
+ - Les prix sont **indicatifs** (France, caviste) ; GRAPP utilise le prix de la carte quand il le lit.
123
+ - À consommer avec modération. L'abus d'alcool est dangereux pour la santé. App réservée aux 18 ans et plus.
SOURCES.md ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sources, bibliothèques et données
2
+
3
+ ## Bibliothèques (open source, Python)
4
+ | Outil | Usage | Lien |
5
+ |---|---|---|
6
+ | Streamlit | Interface web | https://github.com/streamlit/streamlit |
7
+ | sentence-transformers | Embeddings de phrases | https://github.com/UKPLab/sentence-transformers |
8
+ | scikit-learn | TF-IDF (repli hors ligne) | https://github.com/scikit-learn/scikit-learn |
9
+ | pandas / NumPy | Données, calcul vectoriel | https://pandas.pydata.org · https://numpy.org |
10
+ | Tesseract OCR | Lecture de la photo de la carte | https://github.com/tesseract-ocr/tesseract |
11
+ | pytesseract | Lien Python ↔ Tesseract | https://github.com/madmaze/pytesseract |
12
+ | Pillow | Images | https://github.com/python-pillow/Pillow |
13
+ | difflib (bibliothèque standard) | Correspondance floue | https://docs.python.org/3/library/difflib.html |
14
+
15
+ ## Modèle Hugging Face
16
+ - `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` (Reimers & Gurevych, 2019-2020, licence Apache 2.0)
17
+ https://huggingface.co/sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
18
+
19
+ ## Données
20
+ - `data/vins.csv` et `data/dico.csv` : constitués pour le projet (profils typiques d'appellation et définitions),
21
+ rédigés avec l'aide d'une IA générative (voir PROMPTS.md) puis relus. Références publiques utilisées pour les profils :
22
+ INAO, fiches des appellations (https://www.inao.gouv.fr) ; Vin & Société / interprofessions (Vins de Bordeaux,
23
+ Vins du Val de Loire, BIVB Bourgogne, Inter Rhône, Vins d'Alsace).
24
+ - Optionnel : Kaggle *Wine Reviews* (zynicide, avis Wine Enthusiast, 130 k vins), https://www.kaggle.com/datasets/zynicide/wine-reviews
25
+ - `assets/carte_exemple.png` : carte fictive générée par `scripts/generer_carte_exemple.py`.
26
+
27
+ ## Chiffres du contexte (dossier PFE de Manon Thomas, ECV Paris 2025)
28
+ - Baisse de 70 % de la consommation de vin des Français en 60 ans ; 1 exploitation agricole sur 5 avec une activité
29
+ viticole ; le vin = 15 % de la production agricole française en valeur.
30
+ - Concept, nom, ton de voix, palette (#FF5F8A #7D1C4F #F5472C #F4C4B6 #FAEFE7 #D5B5D2) et parcours (Tchin Tchin / Mes p'tits favoris / Dico Vino) : Manon Thomas.
31
+ - Applications concurrentes étudiées : Vivino, Oeni, Vinidaily.
app.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GRAPP — L'app pour choisir ton vin sans te prendre la grappe.
2
+
3
+ Lancer : streamlit run app.py
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import pandas as pd
11
+ import streamlit as st
12
+ from PIL import Image
13
+
14
+ from grapp.carte import ocr_image, trouver_vins
15
+ from grapp.engine import FAMILLES, OCCASIONS, DicoVino, Grapp, Preferences
16
+
17
+ ROOT = Path(__file__).resolve().parent
18
+ FAV_FILE = ROOT / "data" / "favoris.json"
19
+ EMOJI = {"rouge": "🍷", "blanc": "🥂", "rose": "🌸"}
20
+
21
+ st.set_page_config(page_title="GRAPP", page_icon="🍇", layout="centered")
22
+
23
+ # ------------------------------------------------- charte graphique (dossier PFE)
24
+ st.markdown("""
25
+ <style>
26
+ :root { --rose:#FF5F8A; --prune:#7D1C4F; --corail:#F5472C; --peche:#F4C4B6;
27
+ --creme:#FAEFE7; --lilas:#D5B5D2; }
28
+ .stApp { background: var(--creme); }
29
+ h1, h2, h3 { color: var(--prune) !important; font-style: italic; letter-spacing: -.5px; }
30
+ .grapp-logo { font-size: 3.2rem; font-weight: 900; font-style: italic; color: var(--prune);
31
+ line-height: 1; margin: 0; }
32
+ .grapp-tag { color: var(--rose); font-weight: 700; margin-top: .2rem; }
33
+ .card { background: white; border-radius: 18px; padding: 1rem 1.2rem; margin-bottom: .8rem;
34
+ border: 3px solid var(--prune); box-shadow: 5px 5px 0 var(--rose); }
35
+ .card h4 { margin: 0; color: var(--prune); font-size: 1.3rem; text-transform: uppercase; }
36
+ .match { float: right; background: var(--corail); color: white; font-weight: 800;
37
+ border-radius: 999px; padding: .2rem .7rem; }
38
+ .pitch { font-style: italic; color: #444; margin: .3rem 0; }
39
+ .chip { display: inline-block; background: var(--peche); color: var(--prune); border-radius: 999px;
40
+ padding: .1rem .6rem; margin: .15rem .2rem 0 0; font-size: .82rem; font-weight: 600; }
41
+ .stButton > button { border-radius: 999px; border: 2px solid var(--prune); color: var(--prune);
42
+ font-weight: 700; }
43
+ .stButton > button[kind="primary"] { background: var(--rose); border-color: var(--rose); color: white; }
44
+ </style>
45
+ """, unsafe_allow_html=True)
46
+
47
+
48
+ # ------------------------------------------------- chargement (mis en cache)
49
+ @st.cache_resource(show_spinner="On réveille le sommelier IA… 🍇")
50
+ def moteur():
51
+ return Grapp(), DicoVino()
52
+
53
+
54
+ grapp, dico = moteur()
55
+ noms = grapp.vins["nom"].tolist()
56
+ _bk = ROOT / "data" / "bouteilles_kaggle.csv"
57
+ BOUTEILLES = pd.read_csv(_bk) if _bk.exists() else None # optionnel, cf. scripts/enrichir_kaggle.py
58
+
59
+
60
+ def lire_favoris() -> list[str]:
61
+ try:
62
+ return json.loads(FAV_FILE.read_text(encoding="utf-8"))
63
+ except (FileNotFoundError, json.JSONDecodeError):
64
+ return []
65
+
66
+
67
+ def ecrire_favoris(favs: list[str]) -> None:
68
+ FAV_FILE.write_text(json.dumps(favs, ensure_ascii=False, indent=2), encoding="utf-8")
69
+
70
+
71
+ ss = st.session_state
72
+ ss.setdefault("etape", 0) # 0 = carte, 1..7 = quiz, 8 = résultats
73
+ ss.setdefault("rep", {})
74
+ ss.setdefault("carte", None) # {nom: prix} ou None
75
+ ss.setdefault("favoris", lire_favoris())
76
+
77
+ st.markdown('<p class="grapp-logo">GRAPP’</p>'
78
+ '<p class="grapp-tag">L’app pour choisir ton vin sans te prendre la grappe 🍇</p>',
79
+ unsafe_allow_html=True)
80
+ with st.expander("ℹ️ C'est quoi ce sommelier ?"):
81
+ st.caption(f"Moteur sémantique : **{grapp.embedder.backend}** · {len(noms)} vins en base · "
82
+ "OCR Tesseract pour lire la carte · tout tourne en local.")
83
+
84
+ tab_tchin, tab_fav, tab_dico = st.tabs(["🥂 Tchin Tchin !", "💖 Mes p'tits favoris", "💡 Dico Vino"])
85
+
86
+
87
+ # ------------------------------------------------- cartes de vin
88
+ def carte_vin(r, cle: str):
89
+ v = r.vin
90
+ chips = "".join(f'<span class="chip">{x}</span>' for x in r.raisons)
91
+ st.markdown(f"""
92
+ <div class="card"><span class="match">{r.match}% match</span>
93
+ <h4>{EMOJI.get(v['couleur'], '🍷')} {r.nom}</h4>
94
+ <div>{v['region']} · ~{r.prix:.0f} €</div>
95
+ <p class="pitch">« {v['pitch']} »</p>
96
+ <div><b>Ça sent :</b> {v['aromes']} · <b>Avec :</b> {v['accords']}</div>
97
+ <div>{chips}</div></div>""", unsafe_allow_html=True)
98
+ c1, c2 = st.columns(2)
99
+ deja = r.nom in ss.favoris
100
+ if c1.button("💔 Retirer" if deja else "❤️ Mes fav", key=f"fav_{cle}"):
101
+ ss.favoris = [f for f in ss.favoris if f != r.nom] if deja else ss.favoris + [r.nom]
102
+ ecrire_favoris(ss.favoris)
103
+ st.rerun()
104
+ with c2.popover("🔁 Dans le même style"):
105
+ for s in grapp.similaires(r.nom):
106
+ st.write(f"• {s}")
107
+ if BOUTEILLES is not None:
108
+ b = BOUTEILLES[BOUTEILLES["nom"] == r.nom]
109
+ if not b.empty:
110
+ with st.expander("🍾 Vraies bouteilles bien notées (Wine Enthusiast)"):
111
+ for _, x in b.iterrows():
112
+ st.markdown(f"**{x.bouteille}** · {x.points}/100 · {x.prix_usd:.0f} $ \n*{x.avis}*")
113
+
114
+
115
+ # ------------------------------------------------- onglet 1 : Tchin Tchin
116
+ QUESTIONS = [
117
+ ("Alors, on trinque ?", "C'est quoi le plan du jour ?", "occasion",
118
+ list(OCCASIONS.values())),
119
+ ("Verre ou bouteille ?", "Tu la joues solo ou on partage ?", "format",
120
+ ["Juste un p'tit verre (ou 2)", "On prend une bouteille"]),
121
+ ("La vie en couleur !", "T'es dans quelle team ?", "couleur",
122
+ ["Rouge", "Blanc", "Rosé", "Surprends-moi"]),
123
+ ("Plus ça pétille… ", "Un peu de pep's dans ton verre ?", "bulles",
124
+ ["J'adore le pétillant", "Sans bulles", "Peu importe"]),
125
+ ("Sucré ou pas ?", "Sois honnête, personne ne juge.", "sucre",
126
+ ["Sec", "Presque sec", "Une pointe de douceur", "Doux", "Team dessert"]),
127
+ ("Léger ou costaud ?", "Plutôt plume ou plutôt haltère ?", "corps",
128
+ ["Tout léger", "Léger", "Entre les deux", "Costaud", "Ultra costaud"]),
129
+ ("C'est parfait !", "Dernière ligne droite : tes goûts et ton budget.", "final", None),
130
+ ]
131
+
132
+
133
+ def construire_prefs(rep: dict) -> Preferences:
134
+ occ = {v: k for k, v in OCCASIONS.items()}
135
+ return Preferences(
136
+ occasion=occ.get(rep.get("occasion"), "apero"),
137
+ format="verre" if str(rep.get("format", "")).startswith("Juste") else "bouteille",
138
+ couleur={"Rouge": "rouge", "Blanc": "blanc", "Rosé": "rose"}.get(rep.get("couleur"), "peu_importe"),
139
+ bulles={"J'adore le pétillant": "oui", "Sans bulles": "non"}.get(rep.get("bulles"), "peu_importe"),
140
+ sucre=QUESTIONS[4][3].index(rep.get("sucre", "Sec")) + 1,
141
+ corps=QUESTIONS[5][3].index(rep.get("corps", "Entre les deux")) + 1,
142
+ familles=[k for k, v in FAMILLES.items() if v in rep.get("familles", [])],
143
+ budget=float(rep.get("budget", 30)),
144
+ envie=rep.get("envie", ""),
145
+ )
146
+
147
+
148
+ with tab_tchin:
149
+ e = ss.etape
150
+ if e == 0:
151
+ st.subheader("Étape 1 · Scanne la carte du resto 📸")
152
+ st.write("GRAPP lit la carte et ne te propose **que des vins qui y sont**. "
153
+ "Pas de carte ? Pas de souci, passe directement au quiz.")
154
+ source = st.radio("Comment tu nous montres la carte ?",
155
+ ["📷 Photo / fichier", "🧾 Carte d'exemple (démo)", "⌨️ Coller le texte"],
156
+ horizontal=True)
157
+ texte = ""
158
+ if source.startswith("📷"):
159
+ f = st.file_uploader("Photo de la carte des vins", type=["png", "jpg", "jpeg"])
160
+ if f:
161
+ img = Image.open(f)
162
+ st.image(img, width=300)
163
+ elif source.startswith("🧾"):
164
+ img = Image.open(ROOT / "assets" / "carte_exemple.png")
165
+ st.image(img, width=300)
166
+ else:
167
+ img = None
168
+ texte = st.text_area("Colle ici le texte de la carte", height=160)
169
+ if st.button("🔍 Lire la carte", type="primary"):
170
+ try:
171
+ if not source.startswith("⌨️"):
172
+ if source.startswith("📷") and not f:
173
+ st.warning("Ajoute d'abord une photo 😉")
174
+ st.stop()
175
+ with st.spinner("L'IA déchiffre la carte…"):
176
+ texte = ocr_image(img)
177
+ trouves = trouver_vins(texte, noms)
178
+ except RuntimeError as err:
179
+ st.error(f"Lecture impossible : {err}. Colle plutôt le texte de la carte.")
180
+ trouves = []
181
+ if trouves:
182
+ ss.carte = {t["nom"]: t["prix_carte"] for t in trouves}
183
+ ss.etape = 1
184
+ st.rerun()
185
+ elif texte:
186
+ st.warning("Je n'ai reconnu aucun vin de ma base sur cette carte 🤔")
187
+ st.divider()
188
+ if st.button("Pas de carte, on passe au quiz ➡️"):
189
+ ss.carte, ss.etape = None, 1
190
+ st.rerun()
191
+
192
+ elif 1 <= e <= 7:
193
+ if ss.carte:
194
+ st.success(f"🧾 {len(ss.carte)} vins repérés sur la carte : " + ", ".join(ss.carte))
195
+ titre, sous, cle, options = QUESTIONS[e - 1]
196
+ st.progress(e / 7, text=f"{e}/7")
197
+ st.subheader(titre)
198
+ st.caption(sous)
199
+ if options:
200
+ idx = options.index(ss.rep[cle]) if ss.rep.get(cle) in options else 0
201
+ choix = st.radio(" ", options, index=idx, key=f"q{e}", label_visibility="collapsed")
202
+ else:
203
+ fam = st.multiselect("Les saveurs qui te font kiffer", list(FAMILLES.values()),
204
+ default=ss.rep.get("familles", []))
205
+ fmt = "le verre" if str(ss.rep.get("format", "")).startswith("Juste") else "la bouteille"
206
+ bud = st.slider(f"Budget max pour {fmt} (€)", 3, 80,
207
+ int(ss.rep.get("budget", 8 if fmt == "le verre" else 30)))
208
+ env = st.text_input("Ton envie en une phrase (facultatif)",
209
+ value=ss.rep.get("envie", ""),
210
+ placeholder="ex : un truc frais pour un barbecue entre potes")
211
+ c1, c2 = st.columns(2)
212
+ if c1.button("⬅️ Retour"):
213
+ ss.etape -= 1
214
+ st.rerun()
215
+ if c2.button("Suivant ➡️" if e < 7 else "🍇 Trouve mon vin !", type="primary"):
216
+ if options:
217
+ ss.rep[cle] = choix
218
+ else:
219
+ ss.rep.update(familles=fam, budget=bud, envie=env)
220
+ ss.etape += 1
221
+ st.rerun()
222
+
223
+ else:
224
+ prefs = construire_prefs(ss.rep)
225
+ recos = grapp.recommander(prefs, k=3, carte=ss.carte)
226
+ st.subheader("Plus qu'à kiffer ✨")
227
+ if not recos:
228
+ st.warning("Aucun vin ne colle à 100 % à tes critères"
229
+ + (" sur cette carte" if ss.carte else "") + ". Essaie « Surprends-moi » ou « Peu importe » !")
230
+ for i, r in enumerate(recos):
231
+ carte_vin(r, f"reco{i}")
232
+ if st.button("🔄 Recommencer"):
233
+ ss.etape, ss.rep, ss.carte = 0, {}, None
234
+ st.rerun()
235
+
236
+
237
+ # ------------------------------------------------- onglet 2 : favoris
238
+ with tab_fav:
239
+ st.subheader("Mes vins pépites 💖")
240
+ if not ss.favoris:
241
+ st.info("Pas encore de favori. Fais le quiz et clique sur ❤️ !")
242
+ for nom in ss.favoris:
243
+ v = grapp.vins[grapp.vins["nom"] == nom]
244
+ if v.empty:
245
+ continue
246
+ v = v.iloc[0]
247
+ c1, c2 = st.columns([4, 1])
248
+ c1.markdown(f"**{EMOJI.get(v.couleur, '🍷')} {nom}** · {v.region} · ~{v.prix_bouteille} € \n"
249
+ f"*{v.pitch}* \n🔁 Si tu aimes, essaie : {', '.join(grapp.similaires(nom))}")
250
+ if c2.button("🗑️", key=f"del_{nom}"):
251
+ ss.favoris.remove(nom)
252
+ ecrire_favoris(ss.favoris)
253
+ st.rerun()
254
+
255
+
256
+ # ------------------------------------------------- onglet 3 : Dico Vino
257
+ with tab_dico:
258
+ st.subheader("Je décrypte le vin ! 💡")
259
+ st.write("Un mot bizarre sur la carte ? Pose ta question comme tu veux.")
260
+ q = st.text_input("Ta question", placeholder="ex : pourquoi j'ai la bouche qui gratte avec ce rouge ?")
261
+ if q:
262
+ for _, d in dico.chercher(q).iterrows():
263
+ st.markdown(f'<div class="card"><h4>{d.terme}</h4><p>{d.definition}</p>'
264
+ f'<p class="pitch">👉 {d.exemple}</p></div>', unsafe_allow_html=True)
265
+ with st.expander("📖 Tout le dico"):
266
+ for _, d in dico.df.sort_values("terme").iterrows():
267
+ st.markdown(f"**{d.terme}** : {d.definition}")
assets/carte_exemple.png ADDED

Git LFS Details

  • SHA256: 14c36886f043af2414fed60cf9fce98735547b711e34ae511ba78ffaacbd7804
  • Pointer size: 131 Bytes
  • Size of remote file: 109 kB
data/dico.csv ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ terme,definition,exemple
2
+ Tanins,"Ce qui assèche la bouche, comme un thé trop infusé. Vient de la peau et des pépins du raisin, surtout dans les rouges.","Un Cahors a beaucoup de tanins, un Beaujolais très peu."
3
+ Sec,"Pas de sucre qu'on sent. Rien à voir avec un vin qui « assèche » : ça, ce sont les tanins.","Un Chablis est sec."
4
+ Moelleux,"Un peu sucré, mais pas autant qu'un liquoreux.","Un Vouvray moelleux avec une tarte aux pommes."
5
+ Liquoreux,"Très sucré, presque comme du miel. Souvent servi avec le dessert ou le foie gras.","Le Sauternes est le liquoreux star."
6
+ Corps,"La sensation de poids en bouche : léger comme de l'eau ou épais comme du lait entier.","Un Châteauneuf-du-Pape a beaucoup de corps."
7
+ Acidité,"Ce qui fait saliver et donne de la fraîcheur, comme un citron.","Le Muscadet est très acide, donc très frais."
8
+ Cépage,"La variété de raisin, comme les variétés de pommes (Golden, Granny…).","Pinot Noir, Chardonnay, Syrah sont des cépages."
9
+ Appellation (AOP),"Le nom d'un lieu protégé avec des règles de production. Le vin porte le nom du coin, pas du raisin.","Sancerre est une appellation, son cépage est le Sauvignon."
10
+ Millésime,"L'année de la récolte du raisin, écrite sur l'étiquette.","Un Bordeaux 2020."
11
+ Robe,"La couleur du vin quand tu regardes ton verre.","Robe rubis, robe dorée…"
12
+ Nez,"Les odeurs du vin quand tu le sens avant de boire.","Ce rosé a un nez de pêche."
13
+ Bouche,"Ce que tu ressens quand le vin est dans ta bouche : goût, texture, longueur.","Une bouche ronde et fruitée."
14
+ Longueur,"Le temps pendant lequel le goût reste après avoir avalé. Plus c'est long, plus c'est souvent qualitatif.","On dit aussi « caudalies » (1 caudalie = 1 seconde)."
15
+ Minéral,"Une sensation de pierre mouillée, de craie ou de coquillage. Fréquent dans les blancs frais.","Chablis et Sancerre sont très minéraux."
16
+ Boisé,"Des arômes de vanille, de toast ou de bois, qui viennent de l'élevage en fût de chêne.","Un Saint-Émilion souvent boisé."
17
+ Fût de chêne,"Le tonneau en bois où certains vins vieillissent. Il apporte vanille, toasté et rondeur.","Le Meursault est élevé en fût."
18
+ Brut,"Pour les bulles : très peu de sucre. Le plus courant.","Champagne Brut."
19
+ Demi-sec,"Pour les bulles : assez sucré, parfait avec le dessert.","Champagne demi-sec avec le gâteau d'anniversaire."
20
+ Crémant,"Un vin effervescent fait comme le Champagne, mais ailleurs (Loire, Alsace, Bourgogne…) et souvent moins cher.","Crémant de Loire."
21
+ Pet'Nat,"Pétillant naturel : bulles obtenues en une seule fermentation en bouteille. Souvent trouble et funky.","Un Pet'Nat rosé pour l'apéro."
22
+ Vin nature,"Vin fait avec un minimum d'intervention : raisins bio, pas ou peu de sulfites ajoutés.","Un Gamay nature glou-glou."
23
+ Glou-glou,"Un vin léger et fruité qui se boit très facilement.","Un Beaujolais bien frais, c'est glou-glou."
24
+ Sulfites,"Un conservateur (SO2) utilisé presque partout pour protéger le vin.","« Contient des sulfites » sur l'étiquette."
25
+ Carafer,"Verser le vin dans une carafe pour qu'il prenne l'air et s'ouvre.","On carafe un jeune Madiran."
26
+ Bouchonné,"Défaut : le vin sent le carton mouillé ou la cave humide. Tu peux le renvoyer au resto.","Le serveur te fait goûter pour vérifier qu'il n'est pas bouchonné."
27
+ Température de service,"Rouges légers vers 14 °C, rouges costauds vers 17 °C, blancs et rosés vers 8–10 °C, bulles vers 6–8 °C.","Un rouge trop chaud paraît lourd."
28
+ Accord mets-vin,"Choisir un vin qui va bien avec le plat. Règle simple : plat léger, vin léger ; plat riche, vin riche.","Huîtres + Muscadet."
29
+ Sommelier,"La personne qui conseille les vins au restaurant. Pose-lui des questions, c'est son métier !","« Je cherche un rouge léger à moins de 30 € »."
30
+ Rosé de saignée,"Rosé obtenu en retirant du jus d'une cuve de rouge : souvent plus coloré et plus fruité.","Le Tavel est très coloré."
31
+ Grand cru,"Le niveau le plus prestigieux d'un vignoble, surtout en Bourgogne et en Alsace. Souvent cher.","Chambertin Grand Cru."
32
+ Fruité,"Le vin sent et a le goût de fruits : fraise, cerise, pêche, agrumes…","Un Côtes de Provence rosé très fruité."
33
+ Épicé,"Arômes de poivre, de clou de girofle ou de réglisse.","La Syrah du Rhône est poivrée."
34
+ Rond,"Doux et souple en bouche, sans angles : peu d'acidité et peu de tanins.","Un Merlot rond."
35
+ Vif,"Beaucoup d'acidité et de fraîcheur : ça réveille !","Un Sancerre vif."
data/vins.csv ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ nom,region,couleur,bulles,sucre,corps,acidite,tanins,aromes,famille,accords,occasion,prix_bouteille,prix_verre,pitch
2
+ Beaujolais-Villages,Beaujolais,rouge,0,1,2,3,1,fraise cerise bonbon framboise,fruite,charcuterie pizza apéro,apero,11,5,Le rouge qui se boit comme du jus de fruits (mais pour adultes).
3
+ Morgon,Beaujolais,rouge,0,1,3,3,2,cerise noire kirsch violette,fruite,volaille burger,repas,15,6,Le Beaujolais qui a fait de la muscu.
4
+ Fleurie,Beaujolais,rouge,0,1,2,3,1,violette pivoine framboise,floral,saumon salade quiche,apero,15,6,Un bouquet de fleurs dans ton verre.
5
+ Bourgogne Pinot Noir,Bourgogne,rouge,0,1,2,4,2,cerise griotte sous-bois,fruite,volaille champignons,repas,18,7,Le classique chic mais pas prise de tête.
6
+ Gevrey-Chambertin,Bourgogne,rouge,0,1,4,4,3,cassis réglisse sous-bois cuir,boise,boeuf bourguignon gibier,grande_occasion,45,14,Pour impressionner belle-maman.
7
+ Mercurey,Bourgogne,rouge,0,1,3,3,3,fruits rouges épices douces,fruite,veau fromage,repas,22,8,"Bourgogne qualité, prix sympa."
8
+ Côtes du Rhône,Vallée du Rhône,rouge,0,1,3,3,3,fruits noirs poivre garrigue,epice,barbecue pâtes bolo,repas,9,5,Le pote fiable de toutes tes soirées.
9
+ Crozes-Hermitage,Vallée du Rhône,rouge,0,1,3,3,3,mûre poivre noir violette,epice,grillades agneau,repas,17,7,"Syrah poivrée, caractère assumé."
10
+ Châteauneuf-du-Pape,Vallée du Rhône,rouge,0,1,5,2,4,fruits confits garrigue réglisse,epice,daube gibier,grande_occasion,40,13,Le soleil du Sud en mode XXL.
11
+ Gigondas,Vallée du Rhône,rouge,0,1,4,2,4,cerise noire épices cacao,epice,agneau ragoût,repas,25,9,Puissant et généreux comme un câlin.
12
+ Vacqueyras,Vallée du Rhône,rouge,0,1,4,3,3,fruits noirs thym poivre,epice,côte de boeuf,repas,18,7,Costaud mais pas bourrin.
13
+ Bordeaux Supérieur,Bordeaux,rouge,0,1,3,3,4,cassis prune boisé léger,boise,entrecôte fromage,repas,12,6,Le Bordeaux accessible qui ne fait pas peur.
14
+ Saint-Émilion,Bordeaux,rouge,0,1,4,3,3,prune vanille chocolat,boise,magret canard,grande_occasion,28,10,"Merlot velours, ambiance château."
15
+ Pauillac,Bordeaux,rouge,0,1,5,3,5,cassis cèdre tabac,boise,agneau de lait,grande_occasion,60,18,Le boss final des rouges.
16
+ Médoc,Bordeaux,rouge,0,1,4,3,4,cassis graphite boisé,boise,boeuf grillé,repas,16,7,"Structure béton, pour les viandeux."
17
+ Côtes de Bourg,Bordeaux,rouge,0,1,3,3,3,fruits rouges réglisse,fruite,burger pâtes,repas,10,5,"Petit prix, grosse ambiance."
18
+ Chinon,Val de Loire,rouge,0,1,2,4,2,framboise poivron violette,fruite,charcuterie rillettes,apero,13,6,Le rouge frais à servir un peu frais.
19
+ Saumur-Champigny,Val de Loire,rouge,0,1,2,4,2,fruits rouges croquants,fruite,volaille tartes salées,apero,13,6,Croquant comme une fraise des bois.
20
+ Bourgueil,Val de Loire,rouge,0,1,3,4,3,cassis framboise graphite,fruite,porc grillades,repas,13,6,Cabernet franc qui a du peps.
21
+ Cahors,Sud-Ouest,rouge,0,1,5,3,5,mûre prune réglisse,epice,cassoulet confit,repas,14,6,Le vin noir. Ultra costaud.
22
+ Madiran,Sud-Ouest,rouge,0,1,5,3,5,fruits noirs épices cacao,epice,magret garbure,repas,15,6,Tanins de rugbyman.
23
+ Marcillac,Sud-Ouest,rouge,0,1,2,4,2,cassis poivre framboise,fruite,charcuterie aligot,apero,12,6,Le rouge secret de l'Aveyron.
24
+ Corbières,Languedoc,rouge,0,1,4,2,3,fruits noirs garrigue laurier,epice,barbecue tajine,repas,10,5,Le Sud qui sent la garrigue.
25
+ Pic Saint-Loup,Languedoc,rouge,0,1,4,3,3,mûre thym poivre,epice,agneau grillé,repas,18,7,Le Languedoc version premium.
26
+ Minervois,Languedoc,rouge,0,1,3,2,3,cerise épices réglisse,epice,pizza grillades,repas,11,5,"Soleil en bouteille, prix mini."
27
+ Bandol rouge,Provence,rouge,0,1,5,3,5,mûre cuir garrigue,epice,daube agneau,grande_occasion,30,11,Mourvèdre de caractère.
28
+ Alsace Pinot Noir,Alsace,rouge,0,1,1,4,1,cerise fraise légère,fruite,tarte flambée volaille,apero,15,6,Le rouge léger comme une plume.
29
+ Corse Vin rouge,Corse,rouge,0,1,3,3,3,maquis fruits rouges,epice,charcuterie corse,repas,15,6,Le maquis dans ton verre.
30
+ Vin nature Gamay,Divers,rouge,0,1,1,4,1,fruits rouges croquants bonbon,fruite,apéro tapas,apero,16,7,"Glou-glou nature, à boire frais."
31
+ Côtes de Provence rosé,Provence,rose,0,1,2,3,1,pêche agrumes groseille,fruite,salade grillades apéro,apero,13,6,Le rosé pâle de l'été en terrasse.
32
+ Tavel,Vallée du Rhône,rose,0,1,3,3,2,fraise épices cerise,fruite,cuisine asiatique paella,repas,15,6,Le rosé qui se prend pour un rouge.
33
+ Bandol rosé,Provence,rose,0,1,3,3,1,agrumes pêche épices,fruite,bouillabaisse poisson grillé,repas,22,8,Le rosé chic de la Riviera.
34
+ Rosé d'Anjou,Val de Loire,rose,0,3,1,3,1,bonbon fraise framboise,gourmand,apéro dessert fruits,apero,8,4,"Doux et fruité, le rosé bonbon."
35
+ Cabernet d'Anjou,Val de Loire,rose,0,3,2,3,1,fraise framboise sucre d'orge,gourmand,cuisine épicée apéro,apero,9,4,"Rosé gourmand, légèrement sucré."
36
+ Pays d'Oc rosé,Languedoc,rose,0,1,2,3,1,fruits rouges agrumes,fruite,barbecue pique-nique,apero,7,4,"Petit budget, grand soleil."
37
+ Chablis,Bourgogne,blanc,0,1,2,5,1,citron pierre à fusil iode,mineral,huîtres sushis,repas,20,8,"Ultra frais, comme une vague."
38
+ Mâcon-Villages,Bourgogne,blanc,0,1,2,3,1,pomme beurre fleurs blanches,fruite,poisson volaille,apero,12,6,Chardonnay facile et rond.
39
+ Meursault,Bourgogne,blanc,0,1,4,3,1,beurre noisette brioche,boise,homard volaille crème,grande_occasion,50,15,Le blanc beurré de luxe.
40
+ Pouilly-Fuissé,Bourgogne,blanc,0,1,3,3,1,agrumes amande grillée,boise,poisson sauce,repas,28,10,Chardonnay riche et élégant.
41
+ Sancerre,Val de Loire,blanc,0,1,2,5,1,pamplemousse buis silex,mineral,chèvre poisson,repas,22,8,"Vif, net, sans bavure."
42
+ Pouilly-Fumé,Val de Loire,blanc,0,1,2,5,1,agrumes pierre à fusil fumé,mineral,saint-jacques chèvre,repas,22,8,Sauvignon fumé de caractère.
43
+ Muscadet Sèvre-et-Maine,Val de Loire,blanc,0,1,1,5,1,citron iode pomme verte,mineral,huîtres moules,apero,9,5,L'ami des fruits de mer à petit prix.
44
+ Vouvray sec,Val de Loire,blanc,0,1,2,4,1,coing pomme miel,fruite,poisson cuisine asiatique,repas,14,6,Chenin croquant et pomme.
45
+ Vouvray moelleux,Val de Loire,blanc,0,4,3,4,1,miel coing abricot,gourmand,foie gras tarte aux pommes,dessert,18,7,"Doux mais frais, pas écœurant."
46
+ Touraine Sauvignon,Val de Loire,blanc,0,1,1,4,1,agrumes groseille herbe coupée,fruite,apéro salade chèvre,apero,9,5,Le blanc qui réveille.
47
+ Entre-deux-Mers,Bordeaux,blanc,0,1,1,4,1,agrumes fleurs blanches,fruite,poisson apéro,apero,8,4,"Frais et pas cher, validé."
48
+ Sauternes,Bordeaux,blanc,0,5,4,3,1,miel abricot safran,gourmand,foie gras roquefort dessert,dessert,35,12,"Le blanc liquoreux, du miel en bouteille."
49
+ Pessac-Léognan blanc,Bordeaux,blanc,0,1,3,4,1,agrumes boisé fumé,boise,poisson noble,grande_occasion,35,12,Bordeaux blanc grand style.
50
+ Alsace Riesling,Alsace,blanc,0,1,2,5,1,citron vert pétrole léger minéral,mineral,choucroute poisson,repas,14,6,Tranchant comme un laser.
51
+ Alsace Gewurztraminer,Alsace,blanc,0,3,3,2,1,litchi rose épices,floral,cuisine asiatique munster,repas,15,6,"Litchi et rose, très parfumé."
52
+ Alsace Pinot Gris,Alsace,blanc,0,2,3,3,1,poire fumé miel,gourmand,volaille foie gras,repas,15,6,"Rond, un peu fumé, très doux."
53
+ Viognier Pays d'Oc,Languedoc,blanc,0,1,3,2,1,abricot pêche fleurs,floral,curry poulet,apero,10,5,"Parfum d'abricot, très solaire."
54
+ Condrieu,Vallée du Rhône,blanc,0,1,4,2,1,abricot violette pêche,floral,poisson crème,grande_occasion,45,14,Le Viognier haute couture.
55
+ Picpoul de Pinet,Languedoc,blanc,0,1,1,5,1,citron iode fleurs,mineral,huîtres tapas,apero,9,5,Le blanc de la mer du Sud.
56
+ Jurançon sec,Sud-Ouest,blanc,0,1,2,5,1,fruits exotiques agrumes,fruite,poisson cuisine épicée,repas,14,6,Exotique et vif.
57
+ Jurançon moelleux,Sud-Ouest,blanc,0,4,3,4,1,mangue ananas miel,gourmand,dessert fruits foie gras,dessert,18,7,"Ananas et mangue, en mode dessert."
58
+ Côtes de Gascogne blanc,Sud-Ouest,blanc,0,2,1,4,1,pamplemousse fruits exotiques,fruite,apéro salade,apero,7,4,Le blanc tropical à mini prix.
59
+ Vin jaune,Jura,blanc,0,1,4,4,1,noix curry épices,epice,comté poulet aux morilles,grande_occasion,40,13,OVNI du vin : noix et curry.
60
+ Muscat de Beaumes-de-Venise,Vallée du Rhône,blanc,0,5,3,2,1,raisin frais orange fleurs,gourmand,dessert chocolat blanc,dessert,16,6,Comme croquer du raisin sucré.
61
+ Champagne Brut,Champagne,blanc,1,1,2,5,1,brioche pomme agrumes,fruite,apéro sushis tout,grande_occasion,35,12,"Les bulles de la fête, obligées."
62
+ Champagne Rosé,Champagne,rose,1,1,2,4,1,fraise framboise brioche,fruite,apéro dessert fruits rouges,grande_occasion,45,14,Bulles + rose = combo parfait.
63
+ Champagne Demi-sec,Champagne,blanc,1,3,2,4,1,fruits blancs miel brioche,gourmand,dessert gâteau,dessert,35,12,Bulles douces pour le gâteau.
64
+ Crémant de Loire,Val de Loire,blanc,1,1,2,4,1,pomme verte fleurs,fruite,apéro,apero,12,5,Les bulles du Champagne sans le prix.
65
+ Crémant d'Alsace,Alsace,blanc,1,1,2,4,1,agrumes pomme brioche,fruite,apéro tarte flambée,apero,12,5,"Bulles fines, budget mini."
66
+ Crémant de Bourgogne rosé,Bourgogne,rose,1,1,2,4,1,fruits rouges croquants,fruite,apéro,apero,14,6,Bulles roses pour trinquer.
67
+ Clairette de Die,Vallée du Rhône,blanc,1,4,1,3,1,muscat raisin rose,gourmand,dessert brunch,dessert,11,5,"Bulles sucrées et légères, très fun."
68
+ Blanquette de Limoux,Languedoc,blanc,1,1,2,4,1,pomme verte fleurs,fruite,apéro,apero,11,5,Les plus vieilles bulles de France.
69
+ Pétillant naturel (Pet'Nat),Divers,rose,1,2,1,4,1,fruits rouges acidulés,fruite,apéro pique-nique,apero,15,6,"Bulles nature, funky et acidulées."
70
+ Prosecco,Italie,blanc,1,2,1,3,1,poire pomme fleurs,fruite,apéro spritz,apero,10,5,Bulles italiennes légères.
71
+ Cava,Espagne,blanc,1,1,2,4,1,agrumes amande,fruite,tapas apéro,apero,10,5,"Bulles espagnoles, olé."
72
+ Rioja,Espagne,rouge,0,1,4,3,3,cerise vanille cuir,boise,tapas chorizo,repas,14,6,Le rouge espagnol vanillé.
73
+ Chianti,Italie,rouge,0,1,3,4,3,cerise tomate herbes,fruite,pizza pâtes,repas,13,6,Celui qui va avec ta pizza.
74
+ Malbec argentin,Argentine,rouge,0,1,4,3,3,mûre prune violette,fruite,burger asado,repas,13,6,"Gros fruit, ultra facile."
75
+ Pinot Grigio,Italie,blanc,0,1,1,3,1,poire citron,fruite,apéro salade,apero,9,5,"Léger, neutre, safe."
76
+ Porto Tawny,Portugal,rouge,0,5,4,3,2,noix caramel figue,gourmand,chocolat fromage,dessert,20,6,"Caramel et noix, pour finir la soirée."
grapp/__init__.py ADDED
File without changes
grapp/carte.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Étape 1 de GRAPP : lire la carte des vins du resto (photo ou texte).
2
+
3
+ - OCR : Tesseract via `pytesseract` (moteur open source, GitHub tesseract-ocr).
4
+ - Ensuite on retrouve, ligne par ligne, les vins de la carte qui existent dans
5
+ notre base (correspondance floue tolérante aux fautes d'OCR, via difflib),
6
+ et on récupère le prix écrit sur la ligne s'il y en a un.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from difflib import SequenceMatcher
12
+
13
+ from .engine import normaliser
14
+
15
+ PRIX = re.compile(r"(\d{1,3}(?:[.,]\d{1,2})?)\s*(?:€|eur)", re.I)
16
+ PRIX_FIN = re.compile(r"(\d{1,3}(?:[.,]\d{1,2})?)\s*$")
17
+ # mots trop génériques pour identifier un vin à eux seuls
18
+ VIDES = {"vin", "de", "du", "des", "la", "le", "d", "rouge", "blanc", "rose",
19
+ "brut", "sec", "pays", "oc", "aop", "igp", "cotes", "villages"}
20
+
21
+
22
+ def ocr_image(image) -> str:
23
+ """Renvoie le texte d'une image PIL. Lève RuntimeError si Tesseract manque."""
24
+ try:
25
+ import pytesseract
26
+ from PIL import ImageOps
27
+ except ImportError as e: # pragma: no cover
28
+ raise RuntimeError("pytesseract n'est pas installé") from e
29
+ img = ImageOps.grayscale(image)
30
+ if img.width < 1500: # l'OCR aime les grandes images
31
+ f = 1500 / img.width
32
+ img = img.resize((int(img.width * f), int(img.height * f)))
33
+ try:
34
+ langues = pytesseract.get_languages(config="")
35
+ except Exception as e:
36
+ raise RuntimeError("Tesseract n'est pas installé sur cet ordinateur") from e
37
+ lang = "fra" if "fra" in langues else "eng"
38
+ return pytesseract.image_to_string(img, lang=lang, config="--psm 6")
39
+
40
+
41
+ def _score(ligne: str, nom: str) -> float:
42
+ """Proximité entre une ligne de carte et un nom de vin (0 à 1)."""
43
+ n = normaliser(nom)
44
+ if n and n in ligne:
45
+ return 1.0
46
+ mots = [m for m in n.split() if m not in VIDES and len(m) > 2]
47
+ if not mots:
48
+ return 0.0
49
+ toks = ligne.split()
50
+ trouves = 0
51
+ for m in mots:
52
+ best = max((SequenceMatcher(None, m, t).ratio() for t in toks), default=0)
53
+ trouves += best >= 0.8
54
+ return trouves / len(mots)
55
+
56
+
57
+ def trouver_vins(texte: str, noms: list[str], seuil: float = 0.99) -> list[dict]:
58
+ """Associe chaque ligne du texte de la carte au meilleur vin de la base."""
59
+ resultats, deja = [], set()
60
+ for brute in texte.splitlines():
61
+ ligne = normaliser(brute)
62
+ if len(ligne) < 4:
63
+ continue
64
+ scores = sorted(((_score(ligne, n), SequenceMatcher(None, normaliser(n), ligne).ratio(), n)
65
+ for n in noms), reverse=True)
66
+ s, _, nom = scores[0]
67
+ if s >= seuil and nom not in deja:
68
+ m = PRIX.search(brute) or PRIX_FIN.search(brute.strip())
69
+ prix = float(m.group(1).replace(",", ".")) if m else None
70
+ resultats.append({"nom": nom, "ligne": brute.strip(), "prix_carte": prix})
71
+ deja.add(nom)
72
+ return resultats
grapp/engine.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Moteur de recommandation GRAPP.
2
+
3
+ Score hybride pour chaque vin :
4
+ 1. un score "profil" à partir des réponses au quiz (couleur, bulles, sucre,
5
+ corps, famille d'arômes, occasion, budget) ;
6
+ 2. un score "sémantique" entre la phrase libre de l'utilisateur
7
+ (« un truc frais pour un barbecue entre potes ») et la fiche du vin,
8
+ calculé avec des embeddings de phrases.
9
+
10
+ Embeddings : si `sentence-transformers` est installé, on utilise le modèle
11
+ multilingue Hugging Face `paraphrase-multilingual-MiniLM-L12-v2`. Sinon, on
12
+ retombe sur un TF-IDF en n-grammes de caractères (scikit-learn), qui marche
13
+ hors ligne. L'app fonctionne donc dans les deux cas.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import unicodedata
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+
24
+ DATA_DIR = Path(__file__).resolve().parents[1] / "data"
25
+ HF_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
26
+
27
+ FAMILLES = {
28
+ "fruite": "Fruité 🍓",
29
+ "floral": "Floral 🌸",
30
+ "epice": "Épicé 🌶️",
31
+ "boise": "Boisé / vanillé 🪵",
32
+ "mineral": "Frais et minéral 🌊",
33
+ "gourmand": "Gourmand / sucré 🍯",
34
+ }
35
+ OCCASIONS = {
36
+ "apero": "Apéro entre potes",
37
+ "repas": "Avec un bon repas",
38
+ "dessert": "Pour le dessert",
39
+ "grande_occasion": "Grosse occasion",
40
+ }
41
+
42
+
43
+ def normaliser(texte: str) -> str:
44
+ """Minuscules, sans accents ni ponctuation (pour comparer des textes)."""
45
+ t = unicodedata.normalize("NFKD", str(texte)).encode("ascii", "ignore").decode()
46
+ t = "".join(c if c.isalnum() else " " for c in t.lower())
47
+ return " ".join(t.split())
48
+
49
+
50
+ def charger_vins(path: Path | None = None) -> pd.DataFrame:
51
+ df = pd.read_csv(path or DATA_DIR / "vins.csv")
52
+ df["fiche"] = (
53
+ df["nom"] + ". " + df["couleur"] + " " + df["region"] + ". Arômes : "
54
+ + df["aromes"] + ". Accords : " + df["accords"] + ". " + df["pitch"]
55
+ )
56
+ return df
57
+
58
+
59
+ # ---------------------------------------------------------------- embeddings
60
+ class Embedder:
61
+ """Transforme des textes en vecteurs normalisés (cosinus = produit scalaire)."""
62
+
63
+ def __init__(self, corpus: list[str], use_hf: bool = True):
64
+ self.backend = "tfidf"
65
+ self._model = None
66
+ if use_hf:
67
+ try:
68
+ from sentence_transformers import SentenceTransformer
69
+
70
+ self._model = SentenceTransformer(HF_MODEL)
71
+ self.backend = "huggingface"
72
+ except Exception: # pas installé, pas de réseau, etc.
73
+ self._model = None
74
+ if self._model is None:
75
+ from sklearn.feature_extraction.text import TfidfVectorizer
76
+
77
+ self._tfidf = TfidfVectorizer(
78
+ analyzer="char_wb", ngram_range=(3, 5), preprocessor=normaliser
79
+ ).fit(corpus)
80
+
81
+ def encode(self, textes: list[str]) -> np.ndarray:
82
+ if self._model is not None:
83
+ return np.asarray(
84
+ self._model.encode(textes, normalize_embeddings=True), dtype=float
85
+ )
86
+ m = self._tfidf.transform(textes).toarray()
87
+ n = np.linalg.norm(m, axis=1, keepdims=True)
88
+ return m / np.where(n == 0, 1, n)
89
+
90
+
91
+ # ------------------------------------------------------------------ quiz
92
+ @dataclass
93
+ class Preferences:
94
+ format: str = "verre" # verre | bouteille
95
+ couleur: str = "peu_importe" # rouge | blanc | rose | peu_importe
96
+ bulles: str = "peu_importe" # oui | non | peu_importe
97
+ sucre: int = 1 # 1 sec … 5 bien sucré
98
+ corps: int = 3 # 1 léger … 5 costaud
99
+ familles: list[str] = field(default_factory=list)
100
+ occasion: str = "apero"
101
+ budget: float = 30.0 # € max (pour le format choisi)
102
+ envie: str = "" # phrase libre
103
+
104
+
105
+ @dataclass
106
+ class Reco:
107
+ nom: str
108
+ match: int
109
+ prix: float
110
+ raisons: list[str]
111
+ vin: dict
112
+
113
+
114
+ class Grapp:
115
+ POIDS = {"sucre": 0.18, "corps": 0.18, "famille": 0.17, "occasion": 0.12,
116
+ "budget": 0.15, "semantique": 0.20}
117
+
118
+ def __init__(self, vins: pd.DataFrame | None = None, use_hf: bool = True):
119
+ self.vins = charger_vins() if vins is None else vins
120
+ self.embedder = Embedder(self.vins["fiche"].tolist(), use_hf=use_hf)
121
+ self.vecteurs = self.embedder.encode(self.vins["fiche"].tolist())
122
+
123
+ # -- filtres durs : on ne propose jamais un rouge à qui veut du blanc
124
+ def _filtrer(self, p: Preferences, carte: list[str] | None) -> pd.DataFrame:
125
+ df = self.vins
126
+ if p.couleur != "peu_importe":
127
+ df = df[df["couleur"] == p.couleur]
128
+ if p.bulles == "oui":
129
+ df = df[df["bulles"] == 1]
130
+ elif p.bulles == "non":
131
+ df = df[df["bulles"] == 0]
132
+ if carte:
133
+ df = df[df["nom"].isin(list(carte))]
134
+ return df
135
+
136
+ def recommander(self, p: Preferences, k: int = 5,
137
+ carte: dict[str, float | None] | None = None) -> list[Reco]:
138
+ """`carte` = {nom du vin: prix lu sur la carte (ou None)} pour se limiter au resto."""
139
+ df = self._filtrer(p, carte)
140
+ if df.empty:
141
+ return []
142
+ col_prix = "prix_verre" if p.format == "verre" else "prix_bouteille"
143
+
144
+ sem = np.zeros(len(df))
145
+ if p.envie.strip():
146
+ q = self.embedder.encode([p.envie])[0]
147
+ sims = self.vecteurs[df.index.to_numpy()] @ q
148
+ lo, hi = sims.min(), sims.max()
149
+ sem = (sims - lo) / (hi - lo) if hi > lo else np.ones_like(sims)
150
+
151
+ recos = []
152
+ for i, (_, v) in enumerate(df.iterrows()):
153
+ s, raisons = {}, []
154
+ s["sucre"] = 1 - abs(v.sucre - p.sucre) / 4
155
+ s["corps"] = 1 - abs(v.corps - p.corps) / 4
156
+ s["famille"] = 1.0 if not p.familles or v.famille in p.familles else 0.25
157
+ s["occasion"] = 1.0 if v.occasion == p.occasion else 0.4
158
+ prix = float(v[col_prix])
159
+ if carte and carte.get(v.nom):
160
+ prix = float(carte[v.nom]) # le vrai prix du resto
161
+ s["budget"] = 1.0 if prix <= p.budget else max(0.0, 1 - (prix - p.budget) / p.budget)
162
+ s["semantique"] = float(sem[i]) if p.envie.strip() else 0.5
163
+
164
+ if s["sucre"] >= 0.75:
165
+ raisons.append({1: "Sec, comme tu aimes", 2: "Presque sec",
166
+ 3: "Une pointe de douceur", 4: "Doux comme demandé",
167
+ 5: "Bien sucré, team gourmand"}[int(v.sucre)])
168
+ if s["corps"] >= 0.75:
169
+ raisons.append("Léger et facile" if v.corps <= 2 else
170
+ "Équilibré" if v.corps == 3 else "Costaud, du caractère")
171
+ if p.familles and v.famille in p.familles:
172
+ raisons.append(f"Profil {FAMILLES[v.famille]}")
173
+ if v.occasion == p.occasion:
174
+ raisons.append(f"Parfait pour : {OCCASIONS[v.occasion].lower()}")
175
+ if prix <= p.budget:
176
+ unite = "le verre" if p.format == "verre" else "la bouteille"
177
+ raisons.append(f"Dans ton budget ({prix:.0f} € {unite})")
178
+ if p.envie.strip() and s["semantique"] >= 0.7:
179
+ raisons.append("Colle à ton envie du moment")
180
+
181
+ score = sum(self.POIDS[c] * s[c] for c in self.POIDS)
182
+ recos.append(Reco(v.nom, round(100 * score), prix, raisons, v.to_dict()))
183
+ recos.sort(key=lambda r: r.match, reverse=True)
184
+ return recos[:k]
185
+
186
+ def similaires(self, nom: str, k: int = 3) -> list[str]:
187
+ """Vins proches d'un favori (même logique « si tu as aimé… »)."""
188
+ idx = self.vins.index[self.vins["nom"] == nom]
189
+ if idx.empty:
190
+ return []
191
+ sims = self.vecteurs @ self.vecteurs[idx[0]]
192
+ ordre = [j for j in np.argsort(-sims) if j != idx[0]]
193
+ return self.vins.iloc[ordre[:k]]["nom"].tolist()
194
+
195
+
196
+ # ------------------------------------------------------------------ Dico Vino
197
+ class DicoVino:
198
+ def __init__(self, embedder_factory=Embedder, path: Path | None = None, use_hf=True):
199
+ self.df = pd.read_csv(path or DATA_DIR / "dico.csv")
200
+ textes = (self.df["terme"] + " : " + self.df["definition"]).tolist()
201
+ self.embedder = embedder_factory(textes, use_hf=use_hf)
202
+ self.vecteurs = self.embedder.encode(textes)
203
+
204
+ def chercher(self, question: str, k: int = 3) -> pd.DataFrame:
205
+ exact = self.df[self.df["terme"].map(normaliser).str.contains(normaliser(question) or "§")]
206
+ q = self.embedder.encode([question])[0]
207
+ ordre = np.argsort(-(self.vecteurs @ q))[:k]
208
+ res = pd.concat([exact, self.df.iloc[ordre]]).drop_duplicates("terme")
209
+ return res.head(k)
packages.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ tesseract-ocr
2
+ tesseract-ocr-fra
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ streamlit>=1.36
2
+ pandas>=2.0
3
+ numpy>=1.24
4
+ scikit-learn>=1.3
5
+ pillow>=10.0
6
+ pytesseract>=0.3.10
7
+ # IA sémantique Hugging Face (recommandé ; sans elle, GRAPP passe en mode TF-IDF)
8
+ sentence-transformers>=2.7
scripts/build_dataset.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Construit data/vins.csv : base de profils de vins français par appellation.
2
+
3
+ Profils "typiques" d'appellation (pas des avis inventés) : couleur, bulles,
4
+ sucre, corps, acidité, tanins (échelles 1-5), arômes dominants, accords et
5
+ fourchette de prix indicative. Rédigés avec l'aide d'un assistant IA puis
6
+ relus à partir de sources publiques (voir SOURCES.md). À enrichir avec
7
+ scripts/enrichir_kaggle.py (Wine Reviews, Kaggle).
8
+ """
9
+ import csv, pathlib
10
+
11
+ COLS = ["nom","region","couleur","bulles","sucre","corps","acidite","tanins",
12
+ "aromes","famille","accords","occasion","prix_bouteille","prix_verre","pitch"]
13
+ # famille : fruite / floral / epice / boise / mineral / gourmand
14
+ V = [
15
+ ("Beaujolais-Villages","Beaujolais","rouge",0,1,2,3,1,"fraise cerise bonbon framboise","fruite","charcuterie pizza apéro","apero",11,5,"Le rouge qui se boit comme du jus de fruits (mais pour adultes)."),
16
+ ("Morgon","Beaujolais","rouge",0,1,3,3,2,"cerise noire kirsch violette","fruite","volaille burger","repas",15,6,"Le Beaujolais qui a fait de la muscu."),
17
+ ("Fleurie","Beaujolais","rouge",0,1,2,3,1,"violette pivoine framboise","floral","saumon salade quiche","apero",15,6,"Un bouquet de fleurs dans ton verre."),
18
+ ("Bourgogne Pinot Noir","Bourgogne","rouge",0,1,2,4,2,"cerise griotte sous-bois","fruite","volaille champignons","repas",18,7,"Le classique chic mais pas prise de tête."),
19
+ ("Gevrey-Chambertin","Bourgogne","rouge",0,1,4,4,3,"cassis réglisse sous-bois cuir","boise","boeuf bourguignon gibier","grande_occasion",45,14,"Pour impressionner belle-maman."),
20
+ ("Mercurey","Bourgogne","rouge",0,1,3,3,3,"fruits rouges épices douces","fruite","veau fromage","repas",22,8,"Bourgogne qualité, prix sympa."),
21
+ ("Côtes du Rhône","Vallée du Rhône","rouge",0,1,3,3,3,"fruits noirs poivre garrigue","epice","barbecue pâtes bolo","repas",9,5,"Le pote fiable de toutes tes soirées."),
22
+ ("Crozes-Hermitage","Vallée du Rhône","rouge",0,1,3,3,3,"mûre poivre noir violette","epice","grillades agneau","repas",17,7,"Syrah poivrée, caractère assumé."),
23
+ ("Châteauneuf-du-Pape","Vallée du Rhône","rouge",0,1,5,2,4,"fruits confits garrigue réglisse","epice","daube gibier","grande_occasion",40,13,"Le soleil du Sud en mode XXL."),
24
+ ("Gigondas","Vallée du Rhône","rouge",0,1,4,2,4,"cerise noire épices cacao","epice","agneau ragoût","repas",25,9,"Puissant et généreux comme un câlin."),
25
+ ("Vacqueyras","Vallée du Rhône","rouge",0,1,4,3,3,"fruits noirs thym poivre","epice","côte de boeuf","repas",18,7,"Costaud mais pas bourrin."),
26
+ ("Bordeaux Supérieur","Bordeaux","rouge",0,1,3,3,4,"cassis prune boisé léger","boise","entrecôte fromage","repas",12,6,"Le Bordeaux accessible qui ne fait pas peur."),
27
+ ("Saint-Émilion","Bordeaux","rouge",0,1,4,3,3,"prune vanille chocolat","boise","magret canard","grande_occasion",28,10,"Merlot velours, ambiance château."),
28
+ ("Pauillac","Bordeaux","rouge",0,1,5,3,5,"cassis cèdre tabac","boise","agneau de lait","grande_occasion",60,18,"Le boss final des rouges."),
29
+ ("Médoc","Bordeaux","rouge",0,1,4,3,4,"cassis graphite boisé","boise","boeuf grillé","repas",16,7,"Structure béton, pour les viandeux."),
30
+ ("Côtes de Bourg","Bordeaux","rouge",0,1,3,3,3,"fruits rouges réglisse","fruite","burger pâtes","repas",10,5,"Petit prix, grosse ambiance."),
31
+ ("Chinon","Val de Loire","rouge",0,1,2,4,2,"framboise poivron violette","fruite","charcuterie rillettes","apero",13,6,"Le rouge frais à servir un peu frais."),
32
+ ("Saumur-Champigny","Val de Loire","rouge",0,1,2,4,2,"fruits rouges croquants","fruite","volaille tartes salées","apero",13,6,"Croquant comme une fraise des bois."),
33
+ ("Bourgueil","Val de Loire","rouge",0,1,3,4,3,"cassis framboise graphite","fruite","porc grillades","repas",13,6,"Cabernet franc qui a du peps."),
34
+ ("Cahors","Sud-Ouest","rouge",0,1,5,3,5,"mûre prune réglisse","epice","cassoulet confit","repas",14,6,"Le vin noir. Ultra costaud."),
35
+ ("Madiran","Sud-Ouest","rouge",0,1,5,3,5,"fruits noirs épices cacao","epice","magret garbure","repas",15,6,"Tanins de rugbyman."),
36
+ ("Marcillac","Sud-Ouest","rouge",0,1,2,4,2,"cassis poivre framboise","fruite","charcuterie aligot","apero",12,6,"Le rouge secret de l'Aveyron."),
37
+ ("Corbières","Languedoc","rouge",0,1,4,2,3,"fruits noirs garrigue laurier","epice","barbecue tajine","repas",10,5,"Le Sud qui sent la garrigue."),
38
+ ("Pic Saint-Loup","Languedoc","rouge",0,1,4,3,3,"mûre thym poivre","epice","agneau grillé","repas",18,7,"Le Languedoc version premium."),
39
+ ("Minervois","Languedoc","rouge",0,1,3,2,3,"cerise épices réglisse","epice","pizza grillades","repas",11,5,"Soleil en bouteille, prix mini."),
40
+ ("Bandol rouge","Provence","rouge",0,1,5,3,5,"mûre cuir garrigue","epice","daube agneau","grande_occasion",30,11,"Mourvèdre de caractère."),
41
+ ("Alsace Pinot Noir","Alsace","rouge",0,1,1,4,1,"cerise fraise légère","fruite","tarte flambée volaille","apero",15,6,"Le rouge léger comme une plume."),
42
+ ("Corse Vin rouge","Corse","rouge",0,1,3,3,3,"maquis fruits rouges","epice","charcuterie corse","repas",15,6,"Le maquis dans ton verre."),
43
+ ("Vin nature Gamay","Divers","rouge",0,1,1,4,1,"fruits rouges croquants bonbon","fruite","apéro tapas","apero",16,7,"Glou-glou nature, à boire frais."),
44
+ ("Côtes de Provence rosé","Provence","rose",0,1,2,3,1,"pêche agrumes groseille","fruite","salade grillades apéro","apero",13,6,"Le rosé pâle de l'été en terrasse."),
45
+ ("Tavel","Vallée du Rhône","rose",0,1,3,3,2,"fraise épices cerise","fruite","cuisine asiatique paella","repas",15,6,"Le rosé qui se prend pour un rouge."),
46
+ ("Bandol rosé","Provence","rose",0,1,3,3,1,"agrumes pêche épices","fruite","bouillabaisse poisson grillé","repas",22,8,"Le rosé chic de la Riviera."),
47
+ ("Rosé d'Anjou","Val de Loire","rose",0,3,1,3,1,"bonbon fraise framboise","gourmand","apéro dessert fruits","apero",8,4,"Doux et fruité, le rosé bonbon."),
48
+ ("Cabernet d'Anjou","Val de Loire","rose",0,3,2,3,1,"fraise framboise sucre d'orge","gourmand","cuisine épicée apéro","apero",9,4,"Rosé gourmand, légèrement sucré."),
49
+ ("Pays d'Oc rosé","Languedoc","rose",0,1,2,3,1,"fruits rouges agrumes","fruite","barbecue pique-nique","apero",7,4,"Petit budget, grand soleil."),
50
+ ("Chablis","Bourgogne","blanc",0,1,2,5,1,"citron pierre à fusil iode","mineral","huîtres sushis","repas",20,8,"Ultra frais, comme une vague."),
51
+ ("Mâcon-Villages","Bourgogne","blanc",0,1,2,3,1,"pomme beurre fleurs blanches","fruite","poisson volaille","apero",12,6,"Chardonnay facile et rond."),
52
+ ("Meursault","Bourgogne","blanc",0,1,4,3,1,"beurre noisette brioche","boise","homard volaille crème","grande_occasion",50,15,"Le blanc beurré de luxe."),
53
+ ("Pouilly-Fuissé","Bourgogne","blanc",0,1,3,3,1,"agrumes amande grillée","boise","poisson sauce","repas",28,10,"Chardonnay riche et élégant."),
54
+ ("Sancerre","Val de Loire","blanc",0,1,2,5,1,"pamplemousse buis silex","mineral","chèvre poisson","repas",22,8,"Vif, net, sans bavure."),
55
+ ("Pouilly-Fumé","Val de Loire","blanc",0,1,2,5,1,"agrumes pierre à fusil fumé","mineral","saint-jacques chèvre","repas",22,8,"Sauvignon fumé de caractère."),
56
+ ("Muscadet Sèvre-et-Maine","Val de Loire","blanc",0,1,1,5,1,"citron iode pomme verte","mineral","huîtres moules","apero",9,5,"L'ami des fruits de mer à petit prix."),
57
+ ("Vouvray sec","Val de Loire","blanc",0,1,2,4,1,"coing pomme miel","fruite","poisson cuisine asiatique","repas",14,6,"Chenin croquant et pomme."),
58
+ ("Vouvray moelleux","Val de Loire","blanc",0,4,3,4,1,"miel coing abricot","gourmand","foie gras tarte aux pommes","dessert",18,7,"Doux mais frais, pas écœurant."),
59
+ ("Touraine Sauvignon","Val de Loire","blanc",0,1,1,4,1,"agrumes groseille herbe coupée","fruite","apéro salade chèvre","apero",9,5,"Le blanc qui réveille."),
60
+ ("Entre-deux-Mers","Bordeaux","blanc",0,1,1,4,1,"agrumes fleurs blanches","fruite","poisson apéro","apero",8,4,"Frais et pas cher, validé."),
61
+ ("Sauternes","Bordeaux","blanc",0,5,4,3,1,"miel abricot safran","gourmand","foie gras roquefort dessert","dessert",35,12,"Le blanc liquoreux, du miel en bouteille."),
62
+ ("Pessac-Léognan blanc","Bordeaux","blanc",0,1,3,4,1,"agrumes boisé fumé","boise","poisson noble","grande_occasion",35,12,"Bordeaux blanc grand style."),
63
+ ("Alsace Riesling","Alsace","blanc",0,1,2,5,1,"citron vert pétrole léger minéral","mineral","choucroute poisson","repas",14,6,"Tranchant comme un laser."),
64
+ ("Alsace Gewurztraminer","Alsace","blanc",0,3,3,2,1,"litchi rose épices","floral","cuisine asiatique munster","repas",15,6,"Litchi et rose, très parfumé."),
65
+ ("Alsace Pinot Gris","Alsace","blanc",0,2,3,3,1,"poire fumé miel","gourmand","volaille foie gras","repas",15,6,"Rond, un peu fumé, très doux."),
66
+ ("Viognier Pays d'Oc","Languedoc","blanc",0,1,3,2,1,"abricot pêche fleurs","floral","curry poulet","apero",10,5,"Parfum d'abricot, très solaire."),
67
+ ("Condrieu","Vallée du Rhône","blanc",0,1,4,2,1,"abricot violette pêche","floral","poisson crème","grande_occasion",45,14,"Le Viognier haute couture."),
68
+ ("Picpoul de Pinet","Languedoc","blanc",0,1,1,5,1,"citron iode fleurs","mineral","huîtres tapas","apero",9,5,"Le blanc de la mer du Sud."),
69
+ ("Jurançon sec","Sud-Ouest","blanc",0,1,2,5,1,"fruits exotiques agrumes","fruite","poisson cuisine épicée","repas",14,6,"Exotique et vif."),
70
+ ("Jurançon moelleux","Sud-Ouest","blanc",0,4,3,4,1,"mangue ananas miel","gourmand","dessert fruits foie gras","dessert",18,7,"Ananas et mangue, en mode dessert."),
71
+ ("Côtes de Gascogne blanc","Sud-Ouest","blanc",0,2,1,4,1,"pamplemousse fruits exotiques","fruite","apéro salade","apero",7,4,"Le blanc tropical à mini prix."),
72
+ ("Vin jaune","Jura","blanc",0,1,4,4,1,"noix curry épices","epice","comté poulet aux morilles","grande_occasion",40,13,"OVNI du vin : noix et curry."),
73
+ ("Muscat de Beaumes-de-Venise","Vallée du Rhône","blanc",0,5,3,2,1,"raisin frais orange fleurs","gourmand","dessert chocolat blanc","dessert",16,6,"Comme croquer du raisin sucré."),
74
+ ("Champagne Brut","Champagne","blanc",1,1,2,5,1,"brioche pomme agrumes","fruite","apéro sushis tout","grande_occasion",35,12,"Les bulles de la fête, obligées."),
75
+ ("Champagne Rosé","Champagne","rose",1,1,2,4,1,"fraise framboise brioche","fruite","apéro dessert fruits rouges","grande_occasion",45,14,"Bulles + rose = combo parfait."),
76
+ ("Champagne Demi-sec","Champagne","blanc",1,3,2,4,1,"fruits blancs miel brioche","gourmand","dessert gâteau","dessert",35,12,"Bulles douces pour le gâteau."),
77
+ ("Crémant de Loire","Val de Loire","blanc",1,1,2,4,1,"pomme verte fleurs","fruite","apéro","apero",12,5,"Les bulles du Champagne sans le prix."),
78
+ ("Crémant d'Alsace","Alsace","blanc",1,1,2,4,1,"agrumes pomme brioche","fruite","apéro tarte flambée","apero",12,5,"Bulles fines, budget mini."),
79
+ ("Crémant de Bourgogne rosé","Bourgogne","rose",1,1,2,4,1,"fruits rouges croquants","fruite","apéro","apero",14,6,"Bulles roses pour trinquer."),
80
+ ("Clairette de Die","Vallée du Rhône","blanc",1,4,1,3,1,"muscat raisin rose","gourmand","dessert brunch","dessert",11,5,"Bulles sucrées et légères, très fun."),
81
+ ("Blanquette de Limoux","Languedoc","blanc",1,1,2,4,1,"pomme verte fleurs","fruite","apéro","apero",11,5,"Les plus vieilles bulles de France."),
82
+ ("Pétillant naturel (Pet'Nat)","Divers","rose",1,2,1,4,1,"fruits rouges acidulés","fruite","apéro pique-nique","apero",15,6,"Bulles nature, funky et acidulées."),
83
+ ("Prosecco","Italie","blanc",1,2,1,3,1,"poire pomme fleurs","fruite","apéro spritz","apero",10,5,"Bulles italiennes légères."),
84
+ ("Cava","Espagne","blanc",1,1,2,4,1,"agrumes amande","fruite","tapas apéro","apero",10,5,"Bulles espagnoles, olé."),
85
+ ("Rioja","Espagne","rouge",0,1,4,3,3,"cerise vanille cuir","boise","tapas chorizo","repas",14,6,"Le rouge espagnol vanillé."),
86
+ ("Chianti","Italie","rouge",0,1,3,4,3,"cerise tomate herbes","fruite","pizza pâtes","repas",13,6,"Celui qui va avec ta pizza."),
87
+ ("Malbec argentin","Argentine","rouge",0,1,4,3,3,"mûre prune violette","fruite","burger asado","repas",13,6,"Gros fruit, ultra facile."),
88
+ ("Pinot Grigio","Italie","blanc",0,1,1,3,1,"poire citron","fruite","apéro salade","apero",9,5,"Léger, neutre, safe."),
89
+ ("Porto Tawny","Portugal","rouge",0,5,4,3,2,"noix caramel figue","gourmand","chocolat fromage","dessert",20,6,"Caramel et noix, pour finir la soirée."),
90
+ ]
91
+ out = pathlib.Path(__file__).resolve().parents[1] / "data" / "vins.csv"
92
+ with open(out, "w", newline="", encoding="utf-8") as f:
93
+ w = csv.writer(f); w.writerow(COLS); w.writerows(V)
94
+ print(f"{len(V)} vins -> {out}")
scripts/enrichir_kaggle.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """(Optionnel) Ajoute de vraies bouteilles notées à GRAPP avec le dataset Kaggle
2
+ « Wine Reviews » (Wine Enthusiast, 130 000 avis) : https://www.kaggle.com/datasets/zynicide/wine-reviews
3
+
4
+ 1. Télécharger winemag-data-130k-v2.csv (compte Kaggle gratuit), le mettre dans data/
5
+ ou : kaggle datasets download -d zynicide/wine-reviews -p data --unzip
6
+ 2. python scripts/enrichir_kaggle.py
7
+ => crée data/bouteilles_kaggle.csv ; l'app affiche alors, pour chaque vin
8
+ recommandé, les bouteilles réelles les mieux notées de cette appellation.
9
+ """
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import pandas as pd
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ sys.path.insert(0, str(ROOT))
17
+ from grapp.engine import normaliser # noqa: E402
18
+
19
+ src = ROOT / "data" / "winemag-data-130k-v2.csv"
20
+ if not src.exists():
21
+ sys.exit(f"Fichier introuvable : {src}\nTélécharge-le depuis Kaggle (voir l'en-tête du script).")
22
+
23
+ kag = pd.read_csv(src).dropna(subset=["title", "price"])
24
+ kag["t"] = kag["title"].map(normaliser) + " " + kag["designation"].fillna("").map(normaliser)
25
+ vins = pd.read_csv(ROOT / "data" / "vins.csv")
26
+
27
+ lignes = []
28
+ for nom in vins["nom"]:
29
+ cle = normaliser(nom.replace(" rouge", "").replace(" blanc", "").replace(" rosé", ""))
30
+ sel = kag[kag["t"].str.contains(rf"\b{cle}\b", regex=True)]
31
+ for _, r in sel.sort_values(["points", "price"], ascending=[False, True]).head(5).iterrows():
32
+ lignes.append({"nom": nom, "bouteille": r.title, "points": r.points,
33
+ "prix_usd": r.price, "avis": r.description})
34
+ out = pd.DataFrame(lignes)
35
+ out.to_csv(ROOT / "data" / "bouteilles_kaggle.csv", index=False)
36
+ print(f"{len(out)} bouteilles pour {out['nom'].nunique()} appellations -> data/bouteilles_kaggle.csv")
scripts/generer_carte_exemple.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Génère assets/carte_exemple.png : une fausse carte des vins de bistrot pour la démo."""
2
+ from pathlib import Path
3
+ from PIL import Image, ImageDraw, ImageFont
4
+
5
+ LIGNES = [("LA CARTE DES VINS", None), ("", None), ("BULLES", None),
6
+ ("Crémant de Loire, Domaine des Coteaux", "32 €"), ("Champagne Brut, Maison Laurent", "68 €"), ("", None),
7
+ ("BLANCS", None), ("Muscadet Sèvre-et-Maine 2023", "24 €"), ("Sancerre, Les Caillottes 2022", "42 €"),
8
+ ("Alsace Gewurztraminer 2021", "34 €"), ("Chablis 1er Cru 2021", "48 €"), ("", None),
9
+ ("ROSÉS", None), ("Côtes de Provence rosé, Château Sainte-Roseline", "33 €"), ("Tavel 2023", "30 €"), ("", None),
10
+ ("ROUGES", None), ("Beaujolais-Villages 2023", "25 €"), ("Chinon, Les Picasses 2020", "29 €"),
11
+ ("Côtes du Rhône, E. Guigal 2021", "27 €"), ("Saint-Émilion Grand Cru 2018", "58 €"),
12
+ ("Cahors Malbec 2019", "31 €"), ("", None), ("DOUCEURS", None), ("Sauternes 2017 (50 cl)", "45 €")]
13
+ root = Path(__file__).resolve().parents[1]
14
+ W, H = 1100, 60 + 46 * len(LIGNES)
15
+ img = Image.new("RGB", (W, H), "#FFFDF8"); d = ImageDraw.Draw(img)
16
+ def font(bold, size):
17
+ for p in [f"/usr/share/fonts/truetype/dejavu/DejaVuSerif{'-Bold' if bold else ''}.ttf",
18
+ "/Library/Fonts/Georgia.ttf", "/System/Library/Fonts/Supplemental/Georgia.ttf"]:
19
+ try: return ImageFont.truetype(p, size)
20
+ except OSError: pass
21
+ return ImageFont.load_default()
22
+ y = 40
23
+ for txt, prix in LIGNES:
24
+ titre = prix is None and txt.isupper()
25
+ d.text((70, y), txt, fill="#7D1C4F" if titre else "#222", font=font(titre, 30 if titre else 26))
26
+ if prix: d.text((W - 160, y), prix, fill="#222", font=font(False, 26))
27
+ y += 46
28
+ img.save(root / "assets" / "carte_exemple.png"); print("ok", img.size)
tests/test_grapp.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests rapides : python -m pytest -q (ou python tests/test_grapp.py)"""
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
6
+ from grapp.carte import trouver_vins
7
+ from grapp.engine import DicoVino, Grapp, Preferences
8
+
9
+ G = Grapp(use_hf=False)
10
+ NOMS = G.vins["nom"].tolist()
11
+
12
+
13
+ def test_filtre_couleur_et_bulles():
14
+ r = G.recommander(Preferences(couleur="blanc", bulles="oui"), k=10)
15
+ assert r and all(x.vin["couleur"] == "blanc" and x.vin["bulles"] == 1 for x in r)
16
+
17
+
18
+ def test_huitres_donne_un_blanc_mineral():
19
+ p = Preferences(couleur="blanc", familles=["mineral"], occasion="repas",
20
+ format="bouteille", budget=25, envie="huîtres et fruits de mer")
21
+ assert G.recommander(p, k=1)[0].vin["famille"] == "mineral"
22
+
23
+
24
+ def test_lecture_carte_tolere_fautes_ocr():
25
+ texte = "Cétes du Rhone, E. Guigal 2021 27€\nChampagne Brut, Maison X 68 €\nChablis ler Cru 48 €"
26
+ res = {x["nom"]: x["prix_carte"] for x in trouver_vins(texte, NOMS)}
27
+ assert res == {"Côtes du Rhône": 27.0, "Champagne Brut": 68.0, "Chablis": 48.0}
28
+
29
+
30
+ def test_reco_limitee_a_la_carte_et_prix_resto():
31
+ r = G.recommander(Preferences(format="bouteille"), carte={"Chinon": 29.0, "Tavel": 30.0})
32
+ assert {x.nom for x in r} <= {"Chinon", "Tavel"} and {x.prix for x in r} <= {29.0, 30.0}
33
+
34
+
35
+ def test_dico():
36
+ assert "Tanins" in DicoVino(use_hf=False).chercher("tanins")["terme"].tolist()
37
+
38
+
39
+ if __name__ == "__main__":
40
+ for n, f in list(globals().items()):
41
+ if n.startswith("test_"):
42
+ f(); print("OK", n)