Danielos100 commited on
Commit
4e0b9c1
Β·
verified Β·
1 Parent(s): f49a378

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +188 -95
app.py CHANGED
@@ -1,14 +1,15 @@
1
  # app.py
2
- # 🎁 GIfty β€” Smart Gift Recommender (Embeddings + FAISS)
3
  # Dataset: ckandemir/amazon-products (Hugging Face)
4
  # UI: Gradio (English)
5
  #
6
- # Works on common Spaces stacks (no RangeSlider; two sliders for budget)
7
- # Chosen model: sentence-transformers/all-MiniLM-L6-v2 (fast, strong baseline)
8
- #
9
- # Tip: First query builds embeddings+FAISS (cached in-memory).
 
10
 
11
- import os, re, random
12
  from typing import Dict, List, Tuple
13
 
14
  import numpy as np
@@ -18,8 +19,11 @@ from datasets import load_dataset
18
  from sentence_transformers import SentenceTransformer
19
  import faiss
20
 
 
 
 
21
  # ---------------- Config ----------------
22
- MAX_ROWS = int(os.getenv("MAX_ROWS", "6000")) # cap to keep build time reasonable on CPU
23
  TITLE = "# 🎁 GIfty β€” Smart Gift Recommender\n*Top-3 similar picks + 1 generated idea + personalized message*"
24
 
25
  OCCASION_OPTIONS = [
@@ -35,6 +39,8 @@ AGE_OPTIONS = {
35
  "senior (65+)": "senior",
36
  }
37
 
 
 
38
  INTEREST_OPTIONS = [
39
  "reading","writing","tech","travel","fitness","cooking","tea","coffee",
40
  "games","movies","plants","music","design","stationery","home","experience",
@@ -42,7 +48,7 @@ INTEREST_OPTIONS = [
42
  "photography","outdoors","pets","beauty","jewelry"
43
  ]
44
 
45
- # Query-expansion dictionary (improves semantic match with catalog wording)
46
  SYNONYMS = {
47
  "music": ["audio", "headphones", "vinyl", "earbuds", "speaker"],
48
  "tech": ["electronics", "gadgets", "computer", "smart", "device"],
@@ -140,7 +146,7 @@ def load_catalog() -> pd.DataFrame:
140
  ds = load_dataset("ckandemir/amazon-products", split="train")
141
  raw = ds.to_pandas()
142
  except Exception:
143
- # Fallback so the app never crashes if internet is blocked
144
  raw = pd.DataFrame({
145
  "Product Name": ["Wireless Earbuds", "Coffee Sampler", "Strategy Board Game"],
146
  "Description": [
@@ -166,6 +172,19 @@ def _contains_ci(series: pd.Series, needle: str) -> pd.Series:
166
  pat = re.escape(needle)
167
  return series.fillna("").str.contains(pat, case=False, regex=True)
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  def filter_business(df: pd.DataFrame, budget_min=None, budget_max=None,
170
  occasion: str=None, age_range: str="any") -> pd.DataFrame:
171
  m = pd.Series(True, index=df.index)
@@ -179,7 +198,7 @@ def filter_business(df: pd.DataFrame, budget_min=None, budget_max=None,
179
  m &= (df["age_range"].fillna("any").isin([age_range, "any"]))
180
  return df[m]
181
 
182
- # ---------------- Embeddings + FAISS ----------------
183
  class EmbeddingIndex:
184
  def __init__(self, docs: List[str], model_id: str):
185
  self.model_id = model_id
@@ -194,8 +213,7 @@ class EmbeddingIndex:
194
  sims, idxs = self.index.search(qv, topn)
195
  return sims[0], idxs[0]
196
 
197
- # Choose the best all-around model for this app:
198
- EMBED_MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2" # fast & good quality
199
  EMB_INDEX = EmbeddingIndex(CATALOG["doc"].tolist(), EMBED_MODEL_ID)
200
 
201
  # ---------------- Query building ----------------
@@ -209,28 +227,28 @@ def expand_with_synonyms(tokens: List[str]) -> List[str]:
209
  return out
210
 
211
  def profile_to_query(profile: Dict) -> str:
212
- """
213
- Weighted, doc-aligned query (interests + synonyms) + occasion + age.
214
- Repeats interests to give them more weight.
215
- """
216
  interests = [t.strip().lower() for t in profile.get("interests", []) if t.strip()]
217
  expanded = expand_with_synonyms(interests)
218
  expanded = expanded + expanded # weight x2
219
  occasion = (profile.get("occasion", "") or "").lower()
220
  age = profile.get("age_range", "any")
 
221
  parts = []
222
  if expanded: parts.append(", ".join(expanded))
223
  if occasion: parts.append(occasion)
224
  if age and age != "any": parts.append(age)
 
 
225
  return " | ".join(parts).strip()
226
 
227
  def recommend_topk(profile: Dict, k: int=3) -> pd.DataFrame:
228
  query = profile_to_query(profile)
229
 
230
- # Global search on full catalog
231
- sims, idxs = EMB_INDEX.search(query, topn=min(max(k*50, k), len(CATALOG)))
232
 
233
- # Filter down to business subset
234
  df_f = filter_business(
235
  CATALOG,
236
  budget_min=profile.get("budget_min"),
@@ -241,107 +259,180 @@ def recommend_topk(profile: Dict, k: int=3) -> pd.DataFrame:
241
  if df_f.empty:
242
  df_f = CATALOG
243
 
244
- order = np.argsort(-sims)
 
 
 
 
 
 
 
 
 
245
  seen, picks = set(), []
246
- for gi in idxs[order]:
247
- gi = int(gi)
248
- if gi not in df_f.index:
249
- continue
250
  nm = CATALOG.loc[gi, "name"]
251
- if nm in seen:
252
- continue
253
  seen.add(nm)
254
- picks.append(gi)
255
- if len(picks) >= k:
256
- break
257
 
258
  if not picks:
259
  res = df_f.head(k).copy()
260
  res["similarity"] = np.nan
261
  return res[["name","short_desc","price_usd","occasion_tags","persona_fit","age_range","image_url","similarity"]]
262
 
263
- gi_to_sim = {int(i): float(s) for i, s in zip(idxs, sims)}
264
- res = CATALOG.loc[picks].copy()
265
- res["similarity"] = [gi_to_sim.get(int(i), np.nan) for i in picks]
 
266
  return res[["name","short_desc","price_usd","occasion_tags","persona_fit","age_range","image_url","similarity"]]
267
 
268
- # ---------------- Generative item + message ----------------
269
- def generate_item(profile: Dict) -> Dict:
270
- random.seed(42) # stable demo
271
- interests = profile.get("interests", [])
272
- occasion = profile.get("occasion","birthday")
273
- budget = profile.get("budget_max", profile.get("budget_usd", 50)) or 50
274
- age = profile.get("age_range","any")
275
- core = (interests[0] if interests else "hobby").strip() or "hobby"
276
- style = random.choice(["personalized","experience","bundle"])
277
- if style == "personalized":
278
- base_name = f"Custom {core} accessory with initials"
279
- base_desc = f"Thoughtful personalized {core} accessory tailored to their taste."
280
- elif style == "experience":
281
- base_name = f"{core.title()} workshop voucher"
282
- base_desc = f"A guided intro session to explore {core} in a fun, hands-on way."
283
- else:
284
- base_name = f"{core.title()} starter bundle"
285
- base_desc = f"A curated set to kickstart their {core} passion."
286
- if age == "kids":
287
- base_desc += " Suitable for kids with safe, age-appropriate materials."
288
- elif age == "teens":
289
- base_desc += " Trendy pick that suits young enthusiasts."
290
- elif age == "senior":
291
- base_desc += " Comfortable and easy to use."
292
- price = float(np.clip(float(budget), 10, 300))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  return {
294
- "name": f"{base_name} ({occasion})",
295
- "short_desc": base_desc,
296
- "price_usd": price,
297
- "occasion_tags": occasion,
298
- "persona_fit": ", ".join(interests) or "general",
299
- "age_range": age,
300
  "image_url": ""
301
  }
302
 
303
- def generate_message(profile: Dict) -> str:
304
- name = profile.get("recipient_name","Friend")
305
- occasion = profile.get("occasion","birthday")
306
- tone = profile.get("tone","warm and friendly")
307
- return (f"Dear {name},\n"
308
- f"Happy {occasion}! Wishing you health, joy, and wonderful memories. "
309
- f"May your goals come true. With {tone}.")
310
-
311
- # ---------------- Rendering helpers ----------------
 
 
 
 
 
 
 
 
 
 
 
312
  def md_escape(text: str) -> str:
313
  return str(text).replace("|","\\|").replace("*","\\*").replace("_","\\_")
314
 
315
- def render_top3_md(df: pd.DataFrame) -> str:
316
  if df is None or df.empty:
317
- return "_No results found._"
318
- lines = ["**Top-3 recommendations:**\n"]
 
319
  for _, r in df.iterrows():
320
  name = md_escape(r.get("name",""))
321
  desc = md_escape(r.get("short_desc",""))
322
  price = r.get("price_usd")
323
  sim = r.get("similarity")
324
  age = r.get("age_range","any")
325
- img = r.get("image_url","")
326
- if img:
327
- lines.append(f"![ ]({img})")
328
  price_str = f"${price:.0f}" if pd.notna(price) else "N/A"
329
  sim_str = f"{sim:.3f}" if pd.notna(sim) else "β€”"
330
- lines.append(f"**{name}** \n{desc} \nPrice: **{price_str}** Β· Age: `{age}` Β· Similarity: `{sim_str}`\n")
331
- return "\n".join(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
332
 
333
  # ---------------- Gradio UI ----------------
334
  EXAMPLES = [
335
- [["tech","music"], "birthday", 20, 60, "Noa", "adult (18–64)", "warm and friendly"],
336
- [["home","cooking","practical"], "housewarming", 25, 45, "Daniel", "adult (18–64)", "warm"],
337
- [["games","photography"], "birthday", 30, 120, "Omer", "teen (13–17)", "fun"],
338
- [["reading","design","aesthetic"], "thank_you", 15, 35, "Maya", "any", "friendly"],
339
  ]
340
 
341
  def ui_predict(interests_list: List[str], occasion: str, budget_min: float, budget_max: float,
342
- recipient_name: str, age_label: str, tone: str):
343
  try:
344
- # budget sanity
345
  if budget_min is None: budget_min = 20.0
346
  if budget_max is None: budget_max = 60.0
347
  if budget_min > budget_max:
@@ -356,18 +447,19 @@ def ui_predict(interests_list: List[str], occasion: str, budget_min: float, budg
356
  "budget_max": float(budget_max),
357
  "budget_usd": float(budget_max),
358
  "age_range": age_range,
 
359
  "tone": tone or "warm and friendly",
360
  }
361
 
362
  top3 = recommend_topk(profile, k=3)
363
- gen = generate_item(profile)
364
- msg = generate_message(profile)
365
 
366
- top3_md = render_top3_md(top3)
367
  gen_md = f"**{md_escape(gen['name'])}**\n\n{md_escape(gen['short_desc'])}\n\n~${gen['price_usd']:.0f}"
368
- return top3_md, gen_md, msg
369
  except Exception as e:
370
- return f":warning: Error: {e}", "", ""
371
 
372
  with gr.Blocks() as demo:
373
  gr.Markdown(TITLE)
@@ -382,8 +474,9 @@ with gr.Blocks() as demo:
382
  with gr.Row():
383
  occasion = gr.Dropdown(label="Occasion", choices=OCCASION_OPTIONS, value="birthday")
384
  age = gr.Dropdown(label="Age group", choices=list(AGE_OPTIONS.keys()), value="adult (18–64)")
 
385
 
386
- # Two sliders (compatible with older Gradio)
387
  with gr.Row():
388
  budget_min = gr.Slider(label="Min budget (USD)", minimum=5, maximum=500, step=1, value=20)
389
  budget_max = gr.Slider(label="Max budget (USD)", minimum=5, maximum=500, step=1, value=60)
@@ -394,19 +487,19 @@ with gr.Blocks() as demo:
394
 
395
  go = gr.Button("Get GIfty 🎯")
396
 
397
- out_top3 = gr.Markdown(label="Top-3 recommendations")
398
  out_gen = gr.Markdown(label="Generated item")
399
  out_msg = gr.Markdown(label="Personalized message")
400
 
401
  gr.Examples(
402
  EXAMPLES,
403
- [interests, occasion, budget_min, budget_max, recipient_name, age, tone],
404
  label="Quick examples",
405
  )
406
 
407
  go.click(
408
  ui_predict,
409
- [interests, occasion, budget_min, budget_max, recipient_name, age, tone],
410
  [out_top3, out_gen, out_msg]
411
  )
412
 
 
1
  # app.py
2
+ # 🎁 GIfty β€” Smart Gift Recommender (Embeddings + FAISS + LLM generator)
3
  # Dataset: ckandemir/amazon-products (Hugging Face)
4
  # UI: Gradio (English)
5
  #
6
+ # Notes:
7
+ # - Embeddings: sentence-transformers/all-MiniLM-L6-v2 + FAISS IndexFlatIP
8
+ # - LLM generator: google/flan-t5-small (local transformers, no API keys)
9
+ # - Budget uses two sliders (compatible with older Gradio).
10
+ # - Images are rendered as right-side thumbnails per result.
11
 
12
+ import os, re, json, random
13
  from typing import Dict, List, Tuple
14
 
15
  import numpy as np
 
19
  from sentence_transformers import SentenceTransformer
20
  import faiss
21
 
22
+ # LLM (Flan-T5) for generation
23
+ from transformers import pipeline, AutoTokenizer, AutoModelForSeq2SeqLM
24
+
25
  # ---------------- Config ----------------
26
+ MAX_ROWS = int(os.getenv("MAX_ROWS", "6000")) # keep index build fast on CPU
27
  TITLE = "# 🎁 GIfty β€” Smart Gift Recommender\n*Top-3 similar picks + 1 generated idea + personalized message*"
28
 
29
  OCCASION_OPTIONS = [
 
39
  "senior (65+)": "senior",
40
  }
41
 
42
+ GENDER_OPTIONS = ["any", "female", "male", "nonbinary"]
43
+
44
  INTEREST_OPTIONS = [
45
  "reading","writing","tech","travel","fitness","cooking","tea","coffee",
46
  "games","movies","plants","music","design","stationery","home","experience",
 
48
  "photography","outdoors","pets","beauty","jewelry"
49
  ]
50
 
51
+ # Query-expansion dictionary (helps matching catalog wording)
52
  SYNONYMS = {
53
  "music": ["audio", "headphones", "vinyl", "earbuds", "speaker"],
54
  "tech": ["electronics", "gadgets", "computer", "smart", "device"],
 
146
  ds = load_dataset("ckandemir/amazon-products", split="train")
147
  raw = ds.to_pandas()
148
  except Exception:
149
+ # Fallback (keeps the app alive if internet is blocked)
150
  raw = pd.DataFrame({
151
  "Product Name": ["Wireless Earbuds", "Coffee Sampler", "Strategy Board Game"],
152
  "Description": [
 
172
  pat = re.escape(needle)
173
  return series.fillna("").str.contains(pat, case=False, regex=True)
174
 
175
+ def gender_tokens(gender: str) -> List[str]:
176
+ gender = (gender or "any").lower()
177
+ if gender == "female": return ["women", "woman", "female", "her"]
178
+ if gender == "male": return ["men", "man", "male", "him"]
179
+ if gender == "nonbinary": return ["unisex", "gender neutral", "they"]
180
+ return ["unisex"] # "any"
181
+
182
+ def soft_gender_boost(row: pd.Series, gender: str) -> float:
183
+ if not gender or gender == "any": return 0.0
184
+ tokens = gender_tokens(gender)
185
+ blob = f"{row.get('tags','')} {row.get('short_desc','')}".lower()
186
+ return 0.08 if any(t in blob for t in tokens) else 0.0
187
+
188
  def filter_business(df: pd.DataFrame, budget_min=None, budget_max=None,
189
  occasion: str=None, age_range: str="any") -> pd.DataFrame:
190
  m = pd.Series(True, index=df.index)
 
198
  m &= (df["age_range"].fillna("any").isin([age_range, "any"]))
199
  return df[m]
200
 
201
+ # ---------------- Embeddings + FAISS (MiniLM) ----------------
202
  class EmbeddingIndex:
203
  def __init__(self, docs: List[str], model_id: str):
204
  self.model_id = model_id
 
213
  sims, idxs = self.index.search(qv, topn)
214
  return sims[0], idxs[0]
215
 
216
+ EMBED_MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2" # fast & solid on CPU
 
217
  EMB_INDEX = EmbeddingIndex(CATALOG["doc"].tolist(), EMBED_MODEL_ID)
218
 
219
  # ---------------- Query building ----------------
 
227
  return out
228
 
229
  def profile_to_query(profile: Dict) -> str:
230
+ # Weighted, doc-aligned query (interests + synonyms) + occasion + age + gender signal
 
 
 
231
  interests = [t.strip().lower() for t in profile.get("interests", []) if t.strip()]
232
  expanded = expand_with_synonyms(interests)
233
  expanded = expanded + expanded # weight x2
234
  occasion = (profile.get("occasion", "") or "").lower()
235
  age = profile.get("age_range", "any")
236
+ gender = (profile.get("gender", "any") or "any").lower()
237
  parts = []
238
  if expanded: parts.append(", ".join(expanded))
239
  if occasion: parts.append(occasion)
240
  if age and age != "any": parts.append(age)
241
+ if gender and gender != "any":
242
+ parts.append("women" if gender=="female" else ("men" if gender=="male" else "unisex"))
243
  return " | ".join(parts).strip()
244
 
245
  def recommend_topk(profile: Dict, k: int=3) -> pd.DataFrame:
246
  query = profile_to_query(profile)
247
 
248
+ # Global search
249
+ sims, idxs = EMB_INDEX.search(query, topn=min(max(k*80, k), len(CATALOG)))
250
 
251
+ # Business subset
252
  df_f = filter_business(
253
  CATALOG,
254
  budget_min=profile.get("budget_min"),
 
259
  if df_f.empty:
260
  df_f = CATALOG
261
 
262
+ # Gather candidates within the subset and apply a small gender boost
263
+ cand = []
264
+ for i, sim in zip(idxs, sims):
265
+ i = int(i)
266
+ if i in df_f.index:
267
+ boost = soft_gender_boost(CATALOG.loc[i], profile.get("gender","any"))
268
+ cand.append((i, float(sim) + boost))
269
+ cand.sort(key=lambda x: -x[1])
270
+
271
+ # Pick unique by name
272
  seen, picks = set(), []
273
+ for gi, score in cand:
 
 
 
274
  nm = CATALOG.loc[gi, "name"]
275
+ if nm in seen: continue
 
276
  seen.add(nm)
277
+ picks.append((gi, score))
278
+ if len(picks) >= k: break
 
279
 
280
  if not picks:
281
  res = df_f.head(k).copy()
282
  res["similarity"] = np.nan
283
  return res[["name","short_desc","price_usd","occasion_tags","persona_fit","age_range","image_url","similarity"]]
284
 
285
+ sel = [gi for gi,_ in picks]
286
+ res = CATALOG.loc[sel].copy()
287
+ sim_map = dict(picks)
288
+ res["similarity"] = [sim_map.get(int(gi), np.nan) for gi in sel]
289
  return res[["name","short_desc","price_usd","occasion_tags","persona_fit","age_range","image_url","similarity"]]
290
 
291
+ # ---------------- LLM generator (Flan-T5) ----------------
292
+ LLM_ID = "google/flan-t5-small"
293
+ try:
294
+ _tok = AutoTokenizer.from_pretrained(LLM_ID)
295
+ _mdl = AutoModelForSeq2SeqLM.from_pretrained(LLM_ID)
296
+ LLM = pipeline("text2text-generation", model=_mdl, tokenizer=_tok)
297
+ except Exception as e:
298
+ LLM = None
299
+ print("LLM load failed, will fallback to rule-based. Error:", e)
300
+
301
+ def _run_llm(prompt: str, max_new_tokens=128) -> str:
302
+ if LLM is None:
303
+ return ""
304
+ out = LLM(prompt, max_new_tokens=max_new_tokens, do_sample=False, temperature=0.0)
305
+ return out[0]["generated_text"]
306
+
307
+ def _parse_json_maybe(s: str) -> dict:
308
+ try:
309
+ return json.loads(s)
310
+ except Exception:
311
+ # try to extract {...}
312
+ m = re.search(r"\{.*\}", s, flags=re.S)
313
+ if m:
314
+ try:
315
+ return json.loads(m.group(0))
316
+ except Exception:
317
+ return {}
318
+ return {}
319
+
320
+ def llm_generate_item(profile: Dict) -> Dict:
321
+ # Prompt to produce a single gift idea JSON
322
+ prompt = f"""
323
+ You are GIfty, a gift recommender. Create ONE gift idea as JSON with keys:
324
+ name, short_desc, price_usd, occasion_tags, persona_fit.
325
+ Constraints:
326
+ - Fit the recipient profile.
327
+ - price_usd must be a number within the budget range.
328
+ - Keep text concise, friendly, and realistic.
329
+
330
+ Recipient profile:
331
+ interests = {profile.get('interests', [])}
332
+ occasion = {profile.get('occasion','birthday')}
333
+ age_group = {profile.get('age_range','any')}
334
+ gender = {profile.get('gender','any')}
335
+ budget_min = {profile.get('budget_min', 10)}
336
+ budget_max = {profile.get('budget_max', 100)}
337
+
338
+ Return ONLY JSON.
339
+ """
340
+ txt = _run_llm(prompt, max_new_tokens=160)
341
+ data = _parse_json_maybe(txt)
342
+ if not data:
343
+ # fallback rule-based if LLM unavailable or malformed
344
+ core = (profile.get("interests",[ "hobby" ])[0] or "hobby").strip()
345
+ name = f"Custom {core} accessory with initials ({profile.get('occasion','birthday')})"
346
+ return {
347
+ "name": name,
348
+ "short_desc": f"Thoughtful personalized {core} accessory tailored to their taste.",
349
+ "price_usd": float(np.clip(profile.get("budget_max", 50) or 50, 10, 300)),
350
+ "occasion_tags": profile.get("occasion","birthday"),
351
+ "persona_fit": ", ".join(profile.get("interests", [])) or "general",
352
+ "age_range": profile.get("age_range","any"),
353
+ "image_url": ""
354
+ }
355
+ # ensure numeric price and bounds
356
+ try:
357
+ p = float(data.get("price_usd", profile.get("budget_max", 50)))
358
+ except Exception:
359
+ p = float(profile.get("budget_max", 50) or 50)
360
+ p = float(np.clip(p, profile.get("budget_min", 10) or 10, profile.get("budget_max", 300) or 300))
361
  return {
362
+ "name": data.get("name","Gift idea"),
363
+ "short_desc": data.get("short_desc","A thoughtful personalized idea."),
364
+ "price_usd": p,
365
+ "occasion_tags": data.get("occasion_tags", profile.get("occasion","birthday")),
366
+ "persona_fit": data.get("persona_fit", ", ".join(profile.get("interests", [])) or "general"),
367
+ "age_range": profile.get("age_range","any"),
368
  "image_url": ""
369
  }
370
 
371
+ def llm_generate_message(profile: Dict) -> str:
372
+ prompt = f"""
373
+ Write a short, warm greeting message (2–3 sentences) in English for a gift card.
374
+ Recipient name: {profile.get('recipient_name','Friend')}
375
+ Occasion: {profile.get('occasion','birthday')}
376
+ Interests: {', '.join(profile.get('interests', []))}
377
+ Age group: {profile.get('age_range','any')}
378
+ Gender: {profile.get('gender','any')}
379
+ Tone: {profile.get('tone','warm and friendly')}
380
+ Avoid emojis. Keep it sincere and concise.
381
+ """
382
+ txt = _run_llm(prompt, max_new_tokens=90)
383
+ if not txt:
384
+ # fallback
385
+ return (f"Dear {profile.get('recipient_name','Friend')},\n"
386
+ f"Happy {profile.get('occasion','birthday')}! Wishing you health, joy, and wonderful memories. "
387
+ f"With {profile.get('tone','warm and friendly')}.")
388
+ return txt.strip()
389
+
390
+ # ---------------- Rendering helpers (HTML with right-side thumbnail) ----------------
391
  def md_escape(text: str) -> str:
392
  return str(text).replace("|","\\|").replace("*","\\*").replace("_","\\_")
393
 
394
+ def render_top3_html(df: pd.DataFrame) -> str:
395
  if df is None or df.empty:
396
+ return "<em>No results found.</em>"
397
+ # Simple cards with image on the right
398
+ rows = []
399
  for _, r in df.iterrows():
400
  name = md_escape(r.get("name",""))
401
  desc = md_escape(r.get("short_desc",""))
402
  price = r.get("price_usd")
403
  sim = r.get("similarity")
404
  age = r.get("age_range","any")
405
+ img = r.get("image_url","") or ""
 
 
406
  price_str = f"${price:.0f}" if pd.notna(price) else "N/A"
407
  sim_str = f"{sim:.3f}" if pd.notna(sim) else "β€”"
408
+ img_html = f'<img src="{img}" alt="" style="width:84px;height:84px;object-fit:cover;border-radius:10px;margin-left:12px;" />' if img else ""
409
+ card = f"""
410
+ <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:10px;border:1px solid #eee;border-radius:12px;margin-bottom:8px;">
411
+ <div style="flex:1;min-width:0;">
412
+ <div style="font-weight:700;">{name}</div>
413
+ <div style="font-size:0.95em;margin-top:4px;">{desc}</div>
414
+ <div style="font-size:0.9em;margin-top:6px;opacity:0.8;">
415
+ Price: <b>{price_str}</b> Β· Age: <code>{age}</code> Β· Similarity: <code>{sim_str}</code>
416
+ </div>
417
+ </div>
418
+ {img_html}
419
+ </div>
420
+ """
421
+ rows.append(card)
422
+ return "\n".join(rows)
423
 
424
  # ---------------- Gradio UI ----------------
425
  EXAMPLES = [
426
+ [["tech","music"], "birthday", 20, 60, "Noa", "adult (18–64)", "any", "warm and friendly"],
427
+ [["home","cooking","practical"], "housewarming", 25, 45, "Daniel", "adult (18–64)", "male", "warm"],
428
+ [["games","photography"], "birthday", 30, 120, "Omer", "teen (13–17)", "male", "fun"],
429
+ [["reading","design","aesthetic"], "thank_you", 15, 35, "Maya", "any", "female", "friendly"],
430
  ]
431
 
432
  def ui_predict(interests_list: List[str], occasion: str, budget_min: float, budget_max: float,
433
+ recipient_name: str, age_label: str, gender: str, tone: str):
434
  try:
435
+ # sanity
436
  if budget_min is None: budget_min = 20.0
437
  if budget_max is None: budget_max = 60.0
438
  if budget_min > budget_max:
 
447
  "budget_max": float(budget_max),
448
  "budget_usd": float(budget_max),
449
  "age_range": age_range,
450
+ "gender": gender or "any",
451
  "tone": tone or "warm and friendly",
452
  }
453
 
454
  top3 = recommend_topk(profile, k=3)
455
+ gen = llm_generate_item(profile)
456
+ msg = llm_generate_message(profile)
457
 
458
+ top3_html = render_top3_html(top3)
459
  gen_md = f"**{md_escape(gen['name'])}**\n\n{md_escape(gen['short_desc'])}\n\n~${gen['price_usd']:.0f}"
460
+ return top3_html, gen_md, msg
461
  except Exception as e:
462
+ return f"<div style='color:#b00;'>⚠️ Error: {e}</div>", "", ""
463
 
464
  with gr.Blocks() as demo:
465
  gr.Markdown(TITLE)
 
474
  with gr.Row():
475
  occasion = gr.Dropdown(label="Occasion", choices=OCCASION_OPTIONS, value="birthday")
476
  age = gr.Dropdown(label="Age group", choices=list(AGE_OPTIONS.keys()), value="adult (18–64)")
477
+ gender = gr.Dropdown(label="Recipient gender", choices=GENDER_OPTIONS, value="any")
478
 
479
+ # Two budget sliders (compatible with older Gradio)
480
  with gr.Row():
481
  budget_min = gr.Slider(label="Min budget (USD)", minimum=5, maximum=500, step=1, value=20)
482
  budget_max = gr.Slider(label="Max budget (USD)", minimum=5, maximum=500, step=1, value=60)
 
487
 
488
  go = gr.Button("Get GIfty 🎯")
489
 
490
+ out_top3 = gr.HTML(label="Top-3 recommendations") # HTML to support right-side thumbnails
491
  out_gen = gr.Markdown(label="Generated item")
492
  out_msg = gr.Markdown(label="Personalized message")
493
 
494
  gr.Examples(
495
  EXAMPLES,
496
+ [interests, occasion, budget_min, budget_max, recipient_name, age, gender, tone],
497
  label="Quick examples",
498
  )
499
 
500
  go.click(
501
  ui_predict,
502
+ [interests, occasion, budget_min, budget_max, recipient_name, age, gender, tone],
503
  [out_top3, out_gen, out_msg]
504
  )
505