"""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("""
""", unsafe_allow_html=True)
# ------------------------------------------------- chargement (mis en cache)
@st.cache_resource(show_spinner="On réveille le sommelier IA… 🍇")
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('
GRAPP’
'
'L’app pour choisir ton vin sans te prendre la grappe 🍇
',
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'{x}' for x in r.raisons)
st.markdown(f"""
{r.match}% match
{EMOJI.get(v['couleur'], '🍷')} {r.nom}
{v['region']} · ~{r.prix:.0f} €
« {v['pitch']} »
Ça sent : {v['aromes']} · Avec : {v['accords']}
{chips}
""", 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'{d.terme}
{d.definition}
'
f'
👉 {d.exemple}
', 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}")