grapp / app.py
adrescop's picture
Look téléphone (charte Manon)
9668f66 verified
Raw History Blame Contribute Delete
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)
@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('<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}")