Upload 16 files
Browse files- .gitattributes +1 -0
- PROMPTS.md +29 -0
- README.md +119 -6
- SOURCES.md +31 -0
- app.py +267 -0
- assets/carte_exemple.png +3 -0
- data/dico.csv +35 -0
- data/vins.csv +76 -0
- grapp/__init__.py +0 -0
- grapp/carte.py +72 -0
- grapp/engine.py +209 -0
- packages.txt +2 -0
- requirements.txt +8 -0
- scripts/build_dataset.py +94 -0
- scripts/enrichir_kaggle.py +36 -0
- scripts/generer_carte_exemple.py +28 -0
- tests/test_grapp.py +42 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
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)
|