Download app.py from grappapp/grapp: direct link, hf CLI and curl.
- Browser
- Download file 16.2 kB
-
https://huggingface.co/spaces/grappapp/grapp/resolve/main/app.py
- Command line
-
hf download hf://spaces/grappapp/grapp/app.py
-
curl -L -o app.py https://huggingface.co/spaces/grappapp/grapp/resolve/main/app.py
16.2 kB
| """GRAPP — L'app pour choisir ton vin sans te prendre la grappe. | |
| Lancer : streamlit run app.py | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from pathlib import Path | |
| import pandas as pd | |
| import streamlit as st | |
| from PIL import Image | |
| from grapp.carte import ocr_image, trouver_vins | |
| from grapp.engine import FAMILLES, OCCASIONS, DicoVino, Grapp, Preferences | |
| ROOT = Path(__file__).resolve().parent | |
| FAV_FILE = ROOT / "data" / "favoris.json" | |
| EMOJI = {"rouge": "🍷", "blanc": "🥂", "rose": "🌸"} | |
| st.set_page_config(page_title="GRAPP", page_icon="🍇", layout="centered") | |
| # ------------------------------------------------- charte graphique (dossier PFE) | |
| st.markdown(""" | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Bowlby+One&family=Instrument+Sans:ital,wght@0,400;0,600;0,700;1,400&display=swap'); | |
| :root { --rose:#FF5F8A; --prune:#7D1C4F; --corail:#F5472C; --peche:#F4C4B6; | |
| --creme:#FAEFE7; --lilas:#D5B5D2; --encre:#2A0E1D; | |
| --display:"Bowlby One","Arial Black",Impact,sans-serif; | |
| --body:"Instrument Sans",system-ui,-apple-system,sans-serif; } | |
| /* Fond de page + "téléphone" au centre (maquettes du dossier PFE) */ | |
| .stApp, [data-testid="stAppViewContainer"] { background: var(--creme) !important; } | |
| header[data-testid="stHeader"] { background: transparent !important; } | |
| .stApp, .stApp p, .stApp label, .stApp span, .stApp div, .stApp input, .stApp button { font-family: var(--body); } | |
| [data-testid="stMainBlockContainer"], .block-container { | |
| max-width: 420px !important; margin: 1.5rem auto 2.5rem !important; | |
| padding: 1.6rem 1.2rem 1.8rem !important; | |
| background: linear-gradient(170deg, var(--peche), var(--lilas)) !important; | |
| border: 10px solid var(--encre); border-radius: 44px; box-shadow: 10px 10px 0 var(--prune); } | |
| @media (max-width: 480px) { | |
| [data-testid="stMainBlockContainer"], .block-container { | |
| margin: 0 auto !important; border-width: 0; border-radius: 0; box-shadow: none; } } | |
| h1, h2, h3, .stApp h2, .stApp h3 { font-family: var(--display) !important; color: var(--prune) !important; | |
| font-style: italic; text-transform: uppercase; letter-spacing: 0; line-height: 1.05 !important; } | |
| .stApp h3 { font-size: 1.5rem !important; } | |
| .grapp-logo { font-family: var(--display) !important; font-size: 2.8rem !important; color: var(--prune) !important; | |
| font-style: italic; line-height: 1 !important; margin: 0 !important; display: inline-block; transform: rotate(-4deg); } | |
| .grapp-tag { color: var(--prune) !important; font-weight: 700; font-size: .85rem !important; margin: .4rem 0 0 !important; } | |
| [data-testid="stCaptionContainer"], .stCaption { color: var(--prune) !important; font-style: italic; } | |
| /* Onglets en pilules */ | |
| .stTabs [data-baseweb="tab-list"] { gap: 6px; border: none !important; } | |
| .stTabs [data-baseweb="tab-highlight"], .stTabs [data-baseweb="tab-border"] { display: none !important; } | |
| .stTabs [data-baseweb="tab"] { flex: 1; justify-content: center; background: white; border: 2px solid var(--prune) !important; | |
| border-radius: 999px; padding: .35rem .3rem !important; height: auto !important; } | |
| .stTabs [data-baseweb="tab"] p { color: var(--prune) !important; font-weight: 700; font-size: .75rem !important; } | |
| .stTabs [aria-selected="true"] { background: var(--prune) !important; } | |
| .stTabs [aria-selected="true"] p { color: white !important; } | |
| /* Réponses du quiz = gros boutons prune */ | |
| div[role="radiogroup"] { gap: 10px !important; flex-direction: column; } | |
| div[role="radiogroup"] > label { background: var(--prune); border-radius: 12px; padding: .75rem 1rem !important; | |
| box-shadow: 3px 3px 0 var(--rose); margin: 0 !important; width: 100%; cursor: pointer; } | |
| div[role="radiogroup"] > label p { color: white !important; font-weight: 700; } | |
| div[role="radiogroup"] > label:has(input:checked) { background: var(--rose); box-shadow: 3px 3px 0 var(--prune); } | |
| div[role="radiogroup"] > label > div:first-child { display: none; } | |
| /* Boutons */ | |
| .stButton > button { border-radius: 999px !important; border: 2px solid var(--prune) !important; background: white !important; | |
| font-weight: 800; text-transform: uppercase; letter-spacing: .04em; } | |
| .stButton > button p { color: var(--prune) !important; font-weight: 800; font-size: .8rem; } | |
| .stButton > button[kind="primary"] { background: var(--corail) !important; border-color: var(--corail) !important; } | |
| .stButton > button[kind="primary"] p { color: white !important; } | |
| [data-testid="stProgress"] > div > div > div > div { background: var(--rose) !important; } | |
| .stTextInput input, .stTextArea textarea { border-radius: 12px !important; background: white !important; } | |
| /* Cartes de vin */ | |
| .card { background: white; border-radius: 16px; padding: .9rem 1rem; margin-bottom: .6rem; | |
| border: 3px solid var(--prune); box-shadow: 4px 4px 0 var(--rose); } | |
| .card h4 { margin: 0; padding: 0 !important; font-family: var(--display) !important; color: var(--prune) !important; | |
| font-size: 1.05rem !important; text-transform: uppercase; line-height: 1.15; } | |
| .card, .card div, .card p, .card b { color: var(--encre); } | |
| .match { float: right; margin-left: .5rem; background: var(--corail); color: white !important; font-weight: 800; | |
| border-radius: 999px; padding: .15rem .6rem; font-size: .85rem; } | |
| .pitch { font-style: italic; margin: .3rem 0 !important; } | |
| .chip { display: inline-block; background: var(--peche); color: var(--prune) !important; border-radius: 999px; | |
| padding: .1rem .55rem; margin: .15rem .2rem 0 0; font-size: .75rem; font-weight: 700; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # ------------------------------------------------- chargement (mis en cache) | |
| def moteur(): | |
| return Grapp(), DicoVino() | |
| grapp, dico = moteur() | |
| noms = grapp.vins["nom"].tolist() | |
| _bk = ROOT / "data" / "bouteilles_kaggle.csv" | |
| BOUTEILLES = pd.read_csv(_bk) if _bk.exists() else None # optionnel, cf. scripts/enrichir_kaggle.py | |
| def lire_favoris() -> list[str]: | |
| try: | |
| return json.loads(FAV_FILE.read_text(encoding="utf-8")) | |
| except (FileNotFoundError, json.JSONDecodeError): | |
| return [] | |
| def ecrire_favoris(favs: list[str]) -> None: | |
| FAV_FILE.write_text(json.dumps(favs, ensure_ascii=False, indent=2), encoding="utf-8") | |
| ss = st.session_state | |
| ss.setdefault("etape", 0) # 0 = carte, 1..7 = quiz, 8 = résultats | |
| ss.setdefault("rep", {}) | |
| ss.setdefault("carte", None) # {nom: prix} ou None | |
| ss.setdefault("favoris", lire_favoris()) | |
| st.markdown('<p class="grapp-logo">GRAPP’</p>' | |
| '<p class="grapp-tag">L’app pour choisir ton vin sans te prendre la grappe 🍇</p>', | |
| unsafe_allow_html=True) | |
| with st.expander("ℹ️ C'est quoi ce sommelier ?"): | |
| st.caption(f"Moteur sémantique : **{grapp.embedder.backend}** · {len(noms)} vins en base · " | |
| "OCR Tesseract pour lire la carte · tout tourne en local.") | |
| tab_tchin, tab_fav, tab_dico = st.tabs(["🥂 Tchin Tchin !", "💖 Mes p'tits favoris", "💡 Dico Vino"]) | |
| # ------------------------------------------------- cartes de vin | |
| def carte_vin(r, cle: str): | |
| v = r.vin | |
| chips = "".join(f'<span class="chip">{x}</span>' for x in r.raisons) | |
| st.markdown(f""" | |
| <div class="card"><span class="match">{r.match}% match</span> | |
| <h4>{EMOJI.get(v['couleur'], '🍷')} {r.nom}</h4> | |
| <div>{v['region']} · ~{r.prix:.0f} €</div> | |
| <p class="pitch">« {v['pitch']} »</p> | |
| <div><b>Ça sent :</b> {v['aromes']} · <b>Avec :</b> {v['accords']}</div> | |
| <div>{chips}</div></div>""", unsafe_allow_html=True) | |
| c1, c2 = st.columns(2) | |
| deja = r.nom in ss.favoris | |
| if c1.button("💔 Retirer" if deja else "❤️ Mes fav", key=f"fav_{cle}"): | |
| ss.favoris = [f for f in ss.favoris if f != r.nom] if deja else ss.favoris + [r.nom] | |
| ecrire_favoris(ss.favoris) | |
| st.rerun() | |
| with c2.popover("🔁 Dans le même style"): | |
| for s in grapp.similaires(r.nom): | |
| st.write(f"• {s}") | |
| if BOUTEILLES is not None: | |
| b = BOUTEILLES[BOUTEILLES["nom"] == r.nom] | |
| if not b.empty: | |
| with st.expander("🍾 Vraies bouteilles bien notées (Wine Enthusiast)"): | |
| for _, x in b.iterrows(): | |
| st.markdown(f"**{x.bouteille}** · {x.points}/100 · {x.prix_usd:.0f} $ \n*{x.avis}*") | |
| # ------------------------------------------------- onglet 1 : Tchin Tchin | |
| QUESTIONS = [ | |
| ("Alors, on trinque ?", "C'est quoi le plan du jour ?", "occasion", | |
| list(OCCASIONS.values())), | |
| ("Verre ou bouteille ?", "Tu la joues solo ou on partage ?", "format", | |
| ["Juste un p'tit verre (ou 2)", "On prend une bouteille"]), | |
| ("La vie en couleur !", "T'es dans quelle team ?", "couleur", | |
| ["Rouge", "Blanc", "Rosé", "Surprends-moi"]), | |
| ("Plus ça pétille… ", "Un peu de pep's dans ton verre ?", "bulles", | |
| ["J'adore le pétillant", "Sans bulles", "Peu importe"]), | |
| ("Sucré ou pas ?", "Sois honnête, personne ne juge.", "sucre", | |
| ["Sec", "Presque sec", "Une pointe de douceur", "Doux", "Team dessert"]), | |
| ("Léger ou costaud ?", "Plutôt plume ou plutôt haltère ?", "corps", | |
| ["Tout léger", "Léger", "Entre les deux", "Costaud", "Ultra costaud"]), | |
| ("C'est parfait !", "Dernière ligne droite : tes goûts et ton budget.", "final", None), | |
| ] | |
| def construire_prefs(rep: dict) -> Preferences: | |
| occ = {v: k for k, v in OCCASIONS.items()} | |
| return Preferences( | |
| occasion=occ.get(rep.get("occasion"), "apero"), | |
| format="verre" if str(rep.get("format", "")).startswith("Juste") else "bouteille", | |
| couleur={"Rouge": "rouge", "Blanc": "blanc", "Rosé": "rose"}.get(rep.get("couleur"), "peu_importe"), | |
| bulles={"J'adore le pétillant": "oui", "Sans bulles": "non"}.get(rep.get("bulles"), "peu_importe"), | |
| sucre=QUESTIONS[4][3].index(rep.get("sucre", "Sec")) + 1, | |
| corps=QUESTIONS[5][3].index(rep.get("corps", "Entre les deux")) + 1, | |
| familles=[k for k, v in FAMILLES.items() if v in rep.get("familles", [])], | |
| budget=float(rep.get("budget", 30)), | |
| envie=rep.get("envie", ""), | |
| ) | |
| with tab_tchin: | |
| e = ss.etape | |
| if e == 0: | |
| st.subheader("Étape 1 · Scanne la carte du resto 📸") | |
| st.write("GRAPP lit la carte et ne te propose **que des vins qui y sont**. " | |
| "Pas de carte ? Pas de souci, passe directement au quiz.") | |
| source = st.radio("Comment tu nous montres la carte ?", | |
| ["📷 Photo / fichier", "🧾 Carte d'exemple (démo)", "⌨️ Coller le texte"], | |
| horizontal=True) | |
| texte = "" | |
| if source.startswith("📷"): | |
| f = st.file_uploader("Photo de la carte des vins", type=["png", "jpg", "jpeg"]) | |
| if f: | |
| img = Image.open(f) | |
| st.image(img, width=300) | |
| elif source.startswith("🧾"): | |
| img = Image.open(ROOT / "assets" / "carte_exemple.png") | |
| st.image(img, width=300) | |
| else: | |
| img = None | |
| texte = st.text_area("Colle ici le texte de la carte", height=160) | |
| if st.button("🔍 Lire la carte", type="primary"): | |
| try: | |
| if not source.startswith("⌨️"): | |
| if source.startswith("📷") and not f: | |
| st.warning("Ajoute d'abord une photo 😉") | |
| st.stop() | |
| with st.spinner("L'IA déchiffre la carte…"): | |
| try: | |
| texte = ocr_image(img) | |
| except RuntimeError: | |
| # Version web (navigateur) : pas de Tesseract. Pour la carte | |
| # d'exemple, on reprend le texte que Tesseract en a extrait. | |
| if not source.startswith("🧾"): | |
| raise | |
| texte = (ROOT / "data" / "carte_exemple.txt").read_text(encoding="utf-8") | |
| trouves = trouver_vins(texte, noms) | |
| except RuntimeError as err: | |
| st.error(f"Lecture impossible : {err}. Colle plutôt le texte de la carte.") | |
| trouves = [] | |
| if trouves: | |
| ss.carte = {t["nom"]: t["prix_carte"] for t in trouves} | |
| ss.etape = 1 | |
| st.rerun() | |
| elif texte: | |
| st.warning("Je n'ai reconnu aucun vin de ma base sur cette carte 🤔") | |
| st.divider() | |
| if st.button("Pas de carte, on passe au quiz ➡️"): | |
| ss.carte, ss.etape = None, 1 | |
| st.rerun() | |
| elif 1 <= e <= 7: | |
| if ss.carte: | |
| st.success(f"🧾 {len(ss.carte)} vins repérés sur la carte : " + ", ".join(ss.carte)) | |
| titre, sous, cle, options = QUESTIONS[e - 1] | |
| st.progress(e / 7, text=f"{e}/7") | |
| st.subheader(titre) | |
| st.caption(sous) | |
| if options: | |
| idx = options.index(ss.rep[cle]) if ss.rep.get(cle) in options else 0 | |
| choix = st.radio(" ", options, index=idx, key=f"q{e}", label_visibility="collapsed") | |
| else: | |
| fam = st.multiselect("Les saveurs qui te font kiffer", list(FAMILLES.values()), | |
| default=ss.rep.get("familles", [])) | |
| fmt = "le verre" if str(ss.rep.get("format", "")).startswith("Juste") else "la bouteille" | |
| bud = st.slider(f"Budget max pour {fmt} (€)", 3, 80, | |
| int(ss.rep.get("budget", 8 if fmt == "le verre" else 30))) | |
| env = st.text_input("Ton envie en une phrase (facultatif)", | |
| value=ss.rep.get("envie", ""), | |
| placeholder="ex : un truc frais pour un barbecue entre potes") | |
| c1, c2 = st.columns(2) | |
| if c1.button("⬅️ Retour"): | |
| ss.etape -= 1 | |
| st.rerun() | |
| if c2.button("Suivant ➡️" if e < 7 else "🍇 Trouve mon vin !", type="primary"): | |
| if options: | |
| ss.rep[cle] = choix | |
| else: | |
| ss.rep.update(familles=fam, budget=bud, envie=env) | |
| ss.etape += 1 | |
| st.rerun() | |
| else: | |
| prefs = construire_prefs(ss.rep) | |
| recos = grapp.recommander(prefs, k=3, carte=ss.carte) | |
| st.subheader("Plus qu'à kiffer ✨") | |
| if not recos: | |
| st.warning("Aucun vin ne colle à 100 % à tes critères" | |
| + (" sur cette carte" if ss.carte else "") + ". Essaie « Surprends-moi » ou « Peu importe » !") | |
| for i, r in enumerate(recos): | |
| carte_vin(r, f"reco{i}") | |
| if st.button("🔄 Recommencer"): | |
| ss.etape, ss.rep, ss.carte = 0, {}, None | |
| st.rerun() | |
| # ------------------------------------------------- onglet 2 : favoris | |
| with tab_fav: | |
| st.subheader("Mes vins pépites 💖") | |
| if not ss.favoris: | |
| st.info("Pas encore de favori. Fais le quiz et clique sur ❤️ !") | |
| for nom in ss.favoris: | |
| v = grapp.vins[grapp.vins["nom"] == nom] | |
| if v.empty: | |
| continue | |
| v = v.iloc[0] | |
| c1, c2 = st.columns([4, 1]) | |
| c1.markdown(f"**{EMOJI.get(v.couleur, '🍷')} {nom}** · {v.region} · ~{v.prix_bouteille} € \n" | |
| f"*{v.pitch}* \n🔁 Si tu aimes, essaie : {', '.join(grapp.similaires(nom))}") | |
| if c2.button("🗑️", key=f"del_{nom}"): | |
| ss.favoris.remove(nom) | |
| ecrire_favoris(ss.favoris) | |
| st.rerun() | |
| # ------------------------------------------------- onglet 3 : Dico Vino | |
| with tab_dico: | |
| st.subheader("Je décrypte le vin ! 💡") | |
| st.write("Un mot bizarre sur la carte ? Pose ta question comme tu veux.") | |
| q = st.text_input("Ta question", placeholder="ex : pourquoi j'ai la bouche qui gratte avec ce rouge ?") | |
| if q: | |
| for _, d in dico.chercher(q).iterrows(): | |
| st.markdown(f'<div class="card"><h4>{d.terme}</h4><p>{d.definition}</p>' | |
| f'<p class="pitch">👉 {d.exemple}</p></div>', unsafe_allow_html=True) | |
| with st.expander("📖 Tout le dico"): | |
| for _, d in dico.df.sort_values("terme").iterrows(): | |
| st.markdown(f"**{d.terme}** : {d.definition}") | |