File size: 16,209 Bytes
e7194cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9668f66
e7194cd
9668f66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e7194cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fddeb71
 
 
 
 
 
 
 
e7194cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
"""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}")