Danielos100 commited on
Commit
900d247
Β·
verified Β·
1 Parent(s): 152870d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +281 -231
app.py CHANGED
@@ -1,15 +1,9 @@
1
  # app.py
2
- # 🎁 GIfty β€” Smart Gift Recommender (Embeddings + FAISS + LLM)
3
- # Dataset: ckandemir/amazon-products
4
- # UI: Gradio (English)
5
- #
6
- # Features:
7
- # - Sentence-Transformers (MiniLM) + FAISS (cosine via normalized embeddings)
8
- # - LLM generator (Flan-T5-small) for the 4th gift + greeting
9
- # - Relationship & Tone inputs that affect both retrieval weighting and LLM outputs
10
- # - Image thumbnails on the right
11
- # - Quick Examples placed visually at the top via CSS order
12
- # - Budget range: RangeSlider if available, else two Sliders as fallback
13
 
14
  import os, re, json, random
15
  from typing import Dict, List, Tuple
@@ -18,68 +12,119 @@ import numpy as np
18
  import pandas as pd
19
  import gradio as gr
20
  from datasets import load_dataset
 
21
  from sentence_transformers import SentenceTransformer
22
  import faiss
23
- from transformers import pipeline, AutoTokenizer, AutoModelForSeq2SeqLM
 
 
 
 
24
 
25
  # --------------------- Config ---------------------
26
- MAX_ROWS = int(os.getenv("MAX_ROWS", "6000"))
27
- TITLE = "# 🎁 GIfty β€” Smart Gift Recommender\n*Top-3 similar picks + 1 generated idea + personalized message*"
28
 
29
- OCCASION_OPTIONS = [
30
- "birthday", "anniversary", "valentines", "graduation",
31
- "housewarming", "christmas", "hanukkah", "thank_you",
 
 
32
  ]
33
 
34
- RELATIONSHIP_OPTIONS = [
35
- "friend", "close friend", "partner/spouse", "family", "parent",
36
- "sibling", "child", "colleague", "manager", "client", "teacher"
 
37
  ]
38
-
39
- AGE_OPTIONS = {
40
- "any": "any",
41
- "kid (3–12)": "kids",
42
- "teen (13–17)": "teens",
43
- "adult (18–64)": "adult",
44
- "senior (65+)": "senior",
 
 
 
 
 
 
45
  }
46
 
47
- GENDER_OPTIONS = ["any", "female", "male", "nonbinary"]
48
-
49
- TONE_OPTIONS = [
50
- "warm and friendly", "heartfelt and emotional", "playful and fun",
51
- "formal and polite", "professional", "minimalist and concise"
 
 
 
 
 
 
 
 
52
  ]
53
 
54
- INTEREST_OPTIONS = [
55
- "reading","writing","tech","travel","fitness","cooking","tea","coffee",
56
- "games","movies","plants","music","design","stationery","home","experience",
57
- "digital","aesthetic","premium","eco","practical","minimalist","social","party",
58
- "photography","outdoors","pets","beauty","jewelry"
 
 
 
 
 
59
  ]
60
 
61
- # Query expansion (helps match catalog wording)
 
 
 
 
 
 
 
 
 
62
  SYNONYMS = {
63
- "music": ["audio", "headphones", "vinyl", "earbuds", "speaker"],
64
- "tech": ["electronics", "gadgets", "computer", "smart", "device"],
65
- "games": ["board game", "puzzle", "gaming", "toy"],
66
- "home": ["home decor", "kitchen", "appliance", "furniture"],
67
- "cooking": ["kitchen", "cookware", "chef", "bake"],
68
- "fitness": ["sports", "yoga", "run", "workout"],
69
- "photography": ["camera", "lens", "tripod"],
70
- "travel": ["luggage", "passport", "map"],
71
- "beauty": ["skincare", "makeup", "fragrance", "cosmetic"],
72
- "jewelry": ["ring", "necklace", "bracelet"],
73
- "coffee": ["espresso", "mug", "grinder"],
74
- "tea": ["teapot", "infuser"],
75
- "plants": ["garden", "planter", "indoor"],
76
- "reading": ["book", "novel", "literature"],
77
- "writing": ["notebook", "pen", "planner"],
78
- "pets": ["pet", "dog", "cat"],
79
- "outdoors": ["camping", "hiking", "outdoor"],
80
- "eco": ["sustainable", "recycled", "eco"],
81
- "digital": ["online", "voucher"],
82
- "experience": ["voucher", "ticket", "workshop"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  }
84
 
85
  # --------------------- Data loading & schema ---------------------
@@ -90,28 +135,34 @@ def _to_price_usd(x):
90
 
91
  def _infer_age_from_category(cat: str) -> str:
92
  s = (cat or "").lower()
93
- if any(k in s for k in ["baby", "toddler", "infant"]): return "kids"
94
  if "toys & games" in s or "board games" in s or "toy" in s: return "kids"
95
- if any(k in s for k in ["teen", "young adult", "ya"]): return "teens"
96
  return "any"
97
 
98
  def _infer_occasion_tags(cat: str) -> str:
99
  s = (cat or "").lower()
100
- tags = set(["birthday"])
101
- if any(k in s for k in ["home & kitchen","furniture","home dΓ©cor","home decor","garden","tools","appliance","cookware","kitchen"]):
102
- tags.update(["housewarming","thank_you"])
103
  if any(k in s for k in ["beauty","jewelry","watch","fragrance","cosmetic","makeup","skincare"]):
104
  tags.update(["valentines","anniversary"])
105
- if any(k in s for k in ["toys","board game","puzzle","kids","lego"]):
106
- tags.update(["hanukkah","christmas"])
107
  if any(k in s for k in ["office","stationery","notebook","pen","planner"]):
108
- tags.update(["graduation","thank_you"])
109
  if any(k in s for k in ["electronics","camera","audio","headphones","gaming","computer"]):
110
- tags.update(["birthday","christmas"])
111
  if any(k in s for k in ["book","novel","literature"]):
112
- tags.update(["graduation","thank_you"])
113
- if any(k in s for k in ["sports","fitness","outdoor","camping","hiking","run","yoga"]):
114
- tags.update(["birthday"])
 
 
 
 
 
 
115
  return ",".join(sorted(tags))
116
 
117
  def map_amazon_to_schema(df_raw: pd.DataFrame) -> pd.DataFrame:
@@ -133,19 +184,18 @@ def map_amazon_to_schema(df_raw: pd.DataFrame) -> pd.DataFrame:
133
  out["tags"] = out["tags"].astype(str).str.replace("|", ", ").str.lower()
134
  out["persona_fit"] = out["persona_fit"].astype(str).str.lower()
135
  out["occasion_tags"] = out["tags"].map(_infer_occasion_tags)
136
- out["age_range"] = out["tags"].map(_infer_age_from_category).fillna("any")
137
  return out
138
 
139
  def build_doc(row: pd.Series) -> str:
140
- parts = [
141
  str(row.get("name","")),
142
  str(row.get("short_desc","")),
143
  str(row.get("tags","")),
144
  str(row.get("persona_fit","")),
145
  str(row.get("occasion_tags","")),
146
  str(row.get("age_range","")),
147
- ]
148
- return " | ".join([p for p in parts if p])
149
 
150
  def load_catalog() -> pd.DataFrame:
151
  try:
@@ -153,7 +203,7 @@ def load_catalog() -> pd.DataFrame:
153
  raw = ds.to_pandas()
154
  except Exception:
155
  raw = pd.DataFrame({
156
- "Product Name": ["Wireless Earbuds", "Coffee Sampler", "Strategy Board Game"],
157
  "Description": [
158
  "Compact earbuds with noise isolation and long battery life.",
159
  "Four single-origin roasts from small roasters.",
@@ -174,18 +224,17 @@ CATALOG = load_catalog()
174
  # --------------------- Business filters ---------------------
175
  def _contains_ci(series: pd.Series, needle: str) -> pd.Series:
176
  if not needle: return pd.Series(True, index=series.index)
177
- pat = re.escape(needle)
178
- return series.fillna("").str.contains(pat, case=False, regex=True)
179
 
180
  def filter_business(df: pd.DataFrame, budget_min=None, budget_max=None,
181
- occasion: str=None, age_range: str="any") -> pd.DataFrame:
182
  m = pd.Series(True, index=df.index)
183
  if budget_min is not None:
184
  m &= df["price_usd"].fillna(0) >= float(budget_min)
185
  if budget_max is not None:
186
  m &= df["price_usd"].fillna(1e9) <= float(budget_max)
187
- if occasion:
188
- m &= _contains_ci(df["occasion_tags"], occasion)
189
  if age_range and age_range != "any":
190
  m &= (df["age_range"].fillna("any").isin([age_range, "any"]))
191
  return df[m]
@@ -193,36 +242,20 @@ def filter_business(df: pd.DataFrame, budget_min=None, budget_max=None,
193
  # --------------------- Embeddings + FAISS ---------------------
194
  class EmbeddingIndex:
195
  def __init__(self, docs: List[str], model_id: str):
196
- self.model_id = model_id
197
  self.model = SentenceTransformer(model_id)
198
  embs = self.model.encode(docs, convert_to_numpy=True, normalize_embeddings=True)
199
- self.index = faiss.IndexFlatIP(embs.shape[1]) # cosine if normalized
200
  self.index.add(embs)
201
- self.dim = embs.shape[1]
202
 
203
- def search(self, query: str, topn: int) -> Tuple[np.ndarray, np.ndarray]:
204
  qv = self.model.encode([query], convert_to_numpy=True, normalize_embeddings=True)
205
  sims, idxs = self.index.search(qv, topn)
206
  return sims[0], idxs[0]
207
 
208
- EMBED_MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2" # best balance CPU speed/quality
209
  EMB_INDEX = EmbeddingIndex(CATALOG["doc"].tolist(), EMBED_MODEL_ID)
210
 
211
  # --------------------- Query building ---------------------
212
- REL_TO_TOKENS = {
213
- "partner/spouse": ["romantic", "couple"],
214
- "close friend": ["personal", "fun"],
215
- "friend": ["friendly"],
216
- "family": ["family"],
217
- "parent": ["parent"],
218
- "sibling": ["sibling"],
219
- "child": ["kids", "play"],
220
- "colleague": ["office", "work"],
221
- "manager": ["professional"],
222
- "client": ["professional", "thank_you"],
223
- "teacher": ["teacher", "thank_you"]
224
- }
225
-
226
  def expand_with_synonyms(tokens: List[str]) -> List[str]:
227
  out = []
228
  for t in tokens:
@@ -233,47 +266,39 @@ def expand_with_synonyms(tokens: List[str]) -> List[str]:
233
  return out
234
 
235
  def profile_to_query(profile: Dict) -> str:
236
- """Weighted, doc-aligned query (interests+synonyms) + occasion + age + gender + relationship."""
237
- interests = [t.strip().lower() for t in profile.get("interests", []) if t.strip()]
238
- expanded = expand_with_synonyms(interests)
239
  expanded = expanded + expanded # weight x2
240
- occasion = (profile.get("occasion", "") or "").lower()
241
- age = profile.get("age_range", "any")
242
- gender = (profile.get("gender", "any") or "any").lower()
243
- rel = (profile.get("relationship","friend") or "friend").lower()
244
- rel_tokens = REL_TO_TOKENS.get(rel, [])
245
  parts = []
246
  if expanded: parts.append(", ".join(expanded))
247
  if rel_tokens: parts.append(", ".join(rel_tokens))
248
- if occasion: parts.append(occasion)
249
- if age and age != "any": parts.append(age)
250
- if gender and gender != "any":
251
- parts.append("women" if gender=="female" else ("men" if gender=="male" else "unisex"))
252
- return " | ".join(parts).strip()
 
 
253
 
254
  def recommend_topk(profile: Dict, k: int=3) -> pd.DataFrame:
255
  query = profile_to_query(profile)
256
-
257
- # Global search
258
- sims, idxs = EMB_INDEX.search(query, topn=min(max(k*80, k), len(CATALOG)))
259
-
260
- # Business subset
261
  df_f = filter_business(
262
  CATALOG,
263
  budget_min=profile.get("budget_min"),
264
  budget_max=profile.get("budget_max"),
265
- occasion=profile.get("occasion"),
266
  age_range=profile.get("age_range","any"),
267
  )
268
- if df_f.empty:
269
- df_f = CATALOG
270
 
271
- # Small gender-aware re-ranking
272
  def gender_tokens(g: str) -> List[str]:
273
  g = (g or "any").lower()
274
- if g == "female": return ["women", "woman", "female", "her"]
275
- if g == "male": return ["men", "man", "male", "him"]
276
- if g == "nonbinary": return ["unisex", "gender neutral", "they"]
277
  return ["unisex"]
278
 
279
  gts = gender_tokens(profile.get("gender","any"))
@@ -281,12 +306,11 @@ def recommend_topk(profile: Dict, k: int=3) -> pd.DataFrame:
281
  for i, sim in zip(idxs, sims):
282
  i = int(i)
283
  if i in df_f.index:
284
- blob = f"{CATALOG.loc[i, 'tags']} {CATALOG.loc[i, 'short_desc']}".lower()
285
  boost = 0.08 if any(t in blob for t in gts) else 0.0
286
  cand.append((i, float(sim) + boost))
287
  cand.sort(key=lambda x: -x[1])
288
 
289
- # Unique by name
290
  seen, picks = set(), []
291
  for gi, score in cand:
292
  nm = CATALOG.loc[gi, "name"]
@@ -302,11 +326,10 @@ def recommend_topk(profile: Dict, k: int=3) -> pd.DataFrame:
302
 
303
  sel = [gi for gi,_ in picks]
304
  res = CATALOG.loc[sel].copy()
305
- sim_map = dict(picks)
306
- res["similarity"] = [sim_map.get(int(gi), np.nan) for gi in sel]
307
  return res[["name","short_desc","price_usd","occasion_tags","persona_fit","age_range","image_url","similarity"]]
308
 
309
- # --------------------- LLM generator (Flan-T5-small) ---------------------
310
  LLM_ID = "google/flan-t5-small"
311
  try:
312
  _tok = AutoTokenizer.from_pretrained(LLM_ID)
@@ -316,9 +339,8 @@ except Exception as e:
316
  LLM = None
317
  print("LLM load failed, fallback to rule-based. Error:", e)
318
 
319
- def _run_llm(prompt: str, max_new_tokens=128) -> str:
320
- if LLM is None:
321
- return ""
322
  out = LLM(prompt, max_new_tokens=max_new_tokens, do_sample=False, temperature=0.0)
323
  return out[0]["generated_text"]
324
 
@@ -328,42 +350,36 @@ def _parse_json_maybe(s: str) -> dict:
328
  except Exception:
329
  m = re.search(r"\{.*\}", s, flags=re.S)
330
  if m:
331
- try:
332
- return json.loads(m.group(0))
333
- except Exception:
334
- return {}
335
  return {}
336
 
337
  def llm_generate_item(profile: Dict) -> Dict:
338
  prompt = f"""
339
- You are GIfty, a gift recommender. Create ONE gift idea as JSON with keys:
340
- name, short_desc, price_usd, occasion_tags, persona_fit.
341
  Constraints:
342
  - Fit the recipient profile and relationship.
343
- - price_usd must be numeric and within the given budget range.
344
- - Keep text concise, friendly, and realistic.
345
-
346
- Recipient:
347
- name = {profile.get('recipient_name','Friend')}
348
- relationship = {profile.get('relationship','friend')}
349
- gender = {profile.get('gender','any')}
350
- age_group = {profile.get('age_range','any')}
351
- interests = {profile.get('interests', [])}
352
- occasion = {profile.get('occasion','birthday')}
353
- budget_min = {profile.get('budget_min', 10)}
354
- budget_max = {profile.get('budget_max', 100)}
355
-
356
- Return ONLY JSON.
357
  """
358
  txt = _run_llm(prompt, max_new_tokens=180)
359
  data = _parse_json_maybe(txt)
360
  if not data:
361
- core = (profile.get("interests",["hobby"])[0] or "hobby").strip()
362
  return {
363
- "name": f"Custom {core} accessory with initials ({profile.get('occasion','birthday')})",
364
- "short_desc": f"Thoughtful personalized {core} accessory tailored to their taste.",
365
  "price_usd": float(np.clip(profile.get("budget_max", 50) or 50, 10, 300)),
366
- "occasion_tags": profile.get("occasion","birthday"),
367
  "persona_fit": ", ".join(profile.get("interests", [])) or "general",
368
  "age_range": profile.get("age_range","any"),
369
  "image_url": ""
@@ -374,10 +390,10 @@ Return ONLY JSON.
374
  p = float(profile.get("budget_max", 50) or 50)
375
  p = float(np.clip(p, profile.get("budget_min", 10) or 10, profile.get("budget_max", 300) or 300))
376
  return {
377
- "name": data.get("name","Gift idea"),
378
- "short_desc": data.get("short_desc","A thoughtful personalized idea."),
379
  "price_usd": p,
380
- "occasion_tags": data.get("occasion_tags", profile.get("occasion","birthday")),
381
  "persona_fit": data.get("persona_fit", ", ".join(profile.get("interests", [])) or "general"),
382
  "age_range": profile.get("age_range","any"),
383
  "image_url": ""
@@ -385,24 +401,55 @@ Return ONLY JSON.
385
 
386
  def llm_generate_message(profile: Dict) -> str:
387
  prompt = f"""
388
- Write a {profile.get('tone','warm and friendly')} greeting in English (2–3 short sentences) for a gift card.
389
- Use the relationship to set the level of warmth/formality.
390
- Recipient name: {profile.get('recipient_name','Friend')}
391
- Relationship: {profile.get('relationship','friend')}
392
- Occasion: {profile.get('occasion','birthday')}
393
  Interests: {', '.join(profile.get('interests', []))}
394
- Age group: {profile.get('age_range','any')}
395
- Gender: {profile.get('gender','any')}
396
  Avoid emojis.
397
  """
398
  txt = _run_llm(prompt, max_new_tokens=90)
399
  if not txt:
400
  return (f"Dear {profile.get('recipient_name','Friend')}, "
401
- f"happy {profile.get('occasion','birthday')}! Wishing you health, joy, and wonderful memories. "
402
- f"With {profile.get('tone','warm and friendly')}.")
403
  return txt.strip()
404
 
405
- # --------------------- Rendering (HTML cards with right thumbnail) ---------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  def md_escape(text: str) -> str:
407
  return str(text).replace("|","\\|").replace("*","\\*").replace("_","\\_")
408
 
@@ -444,134 +491,137 @@ CSS = """
444
  with gr.Blocks(css=CSS) as demo:
445
  gr.Markdown(TITLE)
446
 
447
- # We'll build the form first (so we can reference components), but show Examples on top via CSS order.
448
  with gr.Column(elem_id="examples"):
449
  gr.Markdown("### Quick examples")
450
- # Placeholders; we will link them after creating components.
451
- # (We will create Examples at the end once components exist.)
452
 
453
  with gr.Column(elem_id="form"):
454
  with gr.Row():
455
  recipient_name = gr.Textbox(label="Recipient name", value="Noa")
456
- relationship = gr.Dropdown(label="Relationship", choices=RELATIONSHIP_OPTIONS, value="friend")
457
 
458
  with gr.Row():
459
  interests = gr.CheckboxGroup(
460
- label="Interests (select a few)",
461
- choices=INTEREST_OPTIONS,
462
- value=["tech","music"],
463
- interactive=True
464
  )
465
 
466
  with gr.Row():
467
- occasion = gr.Dropdown(label="Occasion", choices=OCCASION_OPTIONS, value="birthday")
468
  age = gr.Dropdown(label="Age group", choices=list(AGE_OPTIONS.keys()), value="adult (18–64)")
469
  gender = gr.Dropdown(label="Recipient gender", choices=GENDER_OPTIONS, value="any")
470
 
471
- # Budget: RangeSlider if available, else two sliders fallback
472
  RangeSlider = getattr(gr, "RangeSlider", None)
473
  if RangeSlider is not None:
474
  budget_range = RangeSlider(label="Budget range (USD)", minimum=5, maximum=500, step=1, value=[20, 60])
475
- budget_min, budget_max = None, None # placeholders for signature compatibility
476
  else:
477
  with gr.Row():
478
  budget_min = gr.Slider(label="Min budget (USD)", minimum=5, maximum=500, step=1, value=20)
479
  budget_max = gr.Slider(label="Max budget (USD)", minimum=5, maximum=500, step=1, value=60)
480
  budget_range = gr.State(value=None)
481
 
482
- tone = gr.Dropdown(label="Message tone", choices=TONE_OPTIONS, value="warm and friendly")
483
 
484
  go = gr.Button("Get GIfty 🎯")
485
 
486
  out_top3 = gr.HTML(label="Top-3 recommendations")
487
- out_gen = gr.Markdown(label="Generated item")
 
488
  out_msg = gr.Markdown(label="Personalized message")
489
 
490
- # Now that all inputs exist, render Examples at the top container:
491
- EXAMPLES = [
492
- # interests, occasion, (budget), (or min,max), name, relationship, age, gender, tone
493
- [["tech","music"], "birthday", [20, 60] if RangeSlider else None, 20 if budget_min else None, 60 if budget_max else None, "Noa", "friend", "adult (18–64)", "any", "warm and friendly"],
494
- [["home","cooking","practical"], "housewarming", [25, 45] if RangeSlider else None, 25 if budget_min else None, 45 if budget_max else None, "Daniel", "colleague", "adult (18–64)", "male", "professional"],
495
- [["games","photography"], "birthday", [30, 120] if RangeSlider else None, 30 if budget_min else None, 120 if budget_max else None, "Omer", "close friend", "teen (13–17)", "male", "playful and fun"],
496
- [["reading","design","aesthetic"], "thank_you", [15, 35] if RangeSlider else None, 15 if budget_min else None, 35 if budget_max else None, "Maya", "partner/spouse", "any", "female", "heartfelt and emotional"],
497
- ]
498
-
499
- # Build the list of components according to the active budget control
500
  if RangeSlider:
501
  example_inputs = [interests, occasion, budget_range, recipient_name, relationship, age, gender, tone]
 
 
 
 
 
 
502
  else:
503
  example_inputs = [interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone]
 
 
 
 
 
 
504
 
505
- # Insert the Examples widget into the top column now
506
  with gr.Column(elem_id="examples"):
507
  gr.Examples(EXAMPLES, inputs=example_inputs)
508
 
509
- # --------- Predict function wiring ----------
510
  def ui_predict(
511
- interests_list: List[str], occasion_val: str,
512
- budget_rng_or_min, # either [min,max] or min
513
- maybe_max_or_name, # when RangeSlider -> recipient_name; else -> budget_max
514
- maybe_name_or_rel, # when RangeSlider -> relationship; else -> recipient_name
515
- rel_or_age, # when RangeSlider -> age; else -> relationship
516
- age_or_gender, # when RangeSlider -> gender; else -> age
517
- gender_or_tone, # when RangeSlider -> tone; else -> gender
518
  tone_maybe=None
519
  ):
520
- # Disambiguate inputs based on whether we used RangeSlider or not
521
  use_range = isinstance(budget_rng_or_min, (list, tuple))
522
  if use_range:
523
- budget_min_val = float(budget_rng_or_min[0])
524
- budget_max_val = float(budget_rng_or_min[1])
525
- recipient_name_val = str(maybe_max_or_name or "Friend")
526
- relationship_val = str(maybe_name_or_rel or "friend")
527
- age_label_val = str(rel_or_age or "any")
528
  gender_val = str(age_or_gender or "any")
529
- tone_val = str(gender_or_tone or "warm and friendly")
530
  else:
531
- budget_min_val = float(budget_rng_or_min if budget_rng_or_min is not None else 20)
532
- budget_max_val = float(maybe_max_or_name if maybe_max_or_name is not None else 60)
533
- recipient_name_val = str(maybe_name_or_rel or "Friend")
534
- relationship_val = str(rel_or_age or "friend")
535
- age_label_val = str(age_or_gender or "any")
536
  gender_val = str(gender_or_tone or "any")
537
- tone_val = str(tone_maybe or "warm and friendly")
538
 
539
- if budget_min_val > budget_max_val:
540
- budget_min_val, budget_max_val = budget_max_val, budget_min_val
541
 
542
- age_range = AGE_OPTIONS.get(age_label_val, "any")
543
  profile = {
544
- "recipient_name": recipient_name_val or "Friend",
545
- "relationship": relationship_val or "friend",
546
  "interests": interests_list or [],
547
- "occasion": occasion_val or "birthday",
548
- "budget_min": budget_min_val,
549
- "budget_max": budget_max_val,
550
- "budget_usd": budget_max_val,
551
  "age_range": age_range,
552
  "gender": gender_val or "any",
553
- "tone": tone_val or "warm and friendly",
554
  }
555
 
556
- # Retrieval + generation
557
  top3 = recommend_topk(profile, k=3)
558
- gen = llm_generate_item(profile)
559
- msg = llm_generate_message(profile)
 
 
 
 
 
 
 
560
 
561
- return render_top3_html(top3), f"**{md_escape(gen['name'])}**\n\n{md_escape(gen['short_desc'])}\n\n~${gen['price_usd']:.0f}", msg
562
 
563
- # Wire the button
564
  if RangeSlider:
565
  go.click(
566
  ui_predict,
567
  [interests, occasion, budget_range, recipient_name, relationship, age, gender, tone],
568
- [out_top3, out_gen, out_msg]
569
  )
570
  else:
571
  go.click(
572
  ui_predict,
573
  [interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone],
574
- [out_top3, out_gen, out_msg]
575
  )
576
 
577
  if __name__ == "__main__":
 
1
  # app.py
2
+ # 🎁 GIfty β€” Smart Gift Recommender (Embeddings + FAISS + LLM + Image Gen)
3
+ # Data: ckandemir/amazon-products
4
+ # Retrieval: MiniLM embeddings + FAISS (cosine)
5
+ # Generation: Flan-T5-small (text), SD-Turbo (image)
6
+ # UI: Gradio; Quick Examples on top; Budget range: RangeSlider if present, else two sliders
 
 
 
 
 
 
7
 
8
  import os, re, json, random
9
  from typing import Dict, List, Tuple
 
12
  import pandas as pd
13
  import gradio as gr
14
  from datasets import load_dataset
15
+
16
  from sentence_transformers import SentenceTransformer
17
  import faiss
18
+
19
+ from transformers import AutoTokenizer, AutoModelForSeq2SeqLM, pipeline
20
+
21
+ import torch
22
+ from diffusers import AutoPipelineForText2Image
23
 
24
  # --------------------- Config ---------------------
25
+ MAX_ROWS = int(os.getenv("MAX_ROWS", "8000"))
26
+ TITLE = "# 🎁 GIfty β€” Smart Gift Recommender\n*Top-3 similar picks + 1 invented gift (with image) + personalized message*"
27
 
28
+ # ===== Updated Interests (exact) =====
29
+ INTEREST_OPTIONS = [
30
+ "Sports","Travel","Cooking","Technology","Music","Art","Reading","Gardening","Fashion",
31
+ "Gaming","Photography","Hiking","Movies","Crafts","Pets","Wellness","Collecting","Food",
32
+ "Home decor","Science"
33
  ]
34
 
35
+ # ===== Updated Occasions (exact) =====
36
+ OCCASION_UI = [
37
+ "Birthday","Wedding / Engagement","Anniversary","Graduation","New baby","Housewarming",
38
+ "Retirement","Holidays","Valentine’s Day","Promotion / New job","Get well soon"
39
  ]
40
+ # Canonical tokens used in filtering/query
41
+ OCCASION_CANON = {
42
+ "Birthday":"birthday",
43
+ "Wedding / Engagement":"wedding",
44
+ "Anniversary":"anniversary",
45
+ "Graduation":"graduation",
46
+ "New baby":"new_baby",
47
+ "Housewarming":"housewarming",
48
+ "Retirement":"retirement",
49
+ "Holidays":"holidays",
50
+ "Valentine’s Day":"valentines",
51
+ "Promotion / New job":"promotion",
52
+ "Get well soon":"get_well"
53
  }
54
 
55
+ # ===== Updated Relationship & Tone =====
56
+ RECIPIENT_RELATIONSHIPS = [
57
+ "Family - Parent",
58
+ "Family - Sibling",
59
+ "Family - Child",
60
+ "Family - Other relative",
61
+ "Friend",
62
+ "Colleague",
63
+ "Boss",
64
+ "Romantic partner",
65
+ "Teacher / Mentor",
66
+ "Neighbor",
67
+ "Client / Business partner",
68
  ]
69
 
70
+ MESSAGE_TONES = [
71
+ "Formal",
72
+ "Casual",
73
+ "Funny",
74
+ "Heartfelt",
75
+ "Inspirational",
76
+ "Playful",
77
+ "Romantic",
78
+ "Appreciative",
79
+ "Encouraging",
80
  ]
81
 
82
+ AGE_OPTIONS = {
83
+ "any":"any",
84
+ "kid (3–12)":"kids",
85
+ "teen (13–17)":"teens",
86
+ "adult (18–64)":"adult",
87
+ "senior (65+)":"senior",
88
+ }
89
+ GENDER_OPTIONS = ["any","female","male","nonbinary"]
90
+
91
+ # Query expansion by interest
92
  SYNONYMS = {
93
+ "sports":["fitness","outdoor","training","yoga","run"],
94
+ "travel":["luggage","passport","map","trip","vacation"],
95
+ "cooking":["kitchen","cookware","chef","baking"],
96
+ "technology":["electronics","gadgets","device","smart","computer"],
97
+ "music":["audio","headphones","earbuds","speaker","vinyl"],
98
+ "art":["painting","drawing","sketch","canvas"],
99
+ "reading":["book","novel","literature"],
100
+ "gardening":["plants","planter","seeds","garden","indoor"],
101
+ "fashion":["style","accessory","jewelry"],
102
+ "gaming":["board game","puzzle","video game","controller"],
103
+ "photography":["camera","lens","tripod","film"],
104
+ "hiking":["outdoor","camping","backpack","trek"],
105
+ "movies":["film","cinema","blu-ray","poster"],
106
+ "crafts":["diy","handmade","kit","knitting"],
107
+ "pets":["dog","cat","pet"],
108
+ "wellness":["relaxation","spa","aromatherapy","self-care"],
109
+ "collecting":["display","collector","limited edition"],
110
+ "food":["gourmet","snack","treats","chocolate"],
111
+ "home decor":["home","decor","wall art","candle"],
112
+ "science":["lab","experiment","STEM","microscope"],
113
+ }
114
+
115
+ # Relationship tokens (soft guidance to retrieval)
116
+ REL_TO_TOKENS = {
117
+ "Family - Parent": ["parent", "family"],
118
+ "Family - Sibling": ["sibling", "family"],
119
+ "Family - Child": ["kids", "play", "family"],
120
+ "Family - Other relative": ["family", "relative"],
121
+ "Friend": ["friendly"],
122
+ "Colleague": ["office", "work", "professional"],
123
+ "Boss": ["executive", "professional", "premium"],
124
+ "Romantic partner": ["romantic", "couple"],
125
+ "Teacher / Mentor": ["teacher", "mentor", "thank_you"],
126
+ "Neighbor": ["neighbor", "housewarming"],
127
+ "Client / Business partner": ["professional", "thank_you", "premium"],
128
  }
129
 
130
  # --------------------- Data loading & schema ---------------------
 
135
 
136
  def _infer_age_from_category(cat: str) -> str:
137
  s = (cat or "").lower()
138
+ if any(k in s for k in ["baby","toddler","infant"]): return "kids"
139
  if "toys & games" in s or "board games" in s or "toy" in s: return "kids"
140
+ if any(k in s for k in ["teen","young adult","ya"]): return "teens"
141
  return "any"
142
 
143
  def _infer_occasion_tags(cat: str) -> str:
144
  s = (cat or "").lower()
145
+ tags = set(["birthday"]) # default
146
+ if any(k in s for k in ["home & kitchen","furniture","home dΓ©cor","home decor","garden","appliance","cookware","kitchen"]):
147
+ tags.update(["housewarming"])
148
  if any(k in s for k in ["beauty","jewelry","watch","fragrance","cosmetic","makeup","skincare"]):
149
  tags.update(["valentines","anniversary"])
150
+ if any(k in s for k in ["toys","board game","puzzle","lego","kids"]):
151
+ tags.update(["holidays"])
152
  if any(k in s for k in ["office","stationery","notebook","pen","planner"]):
153
+ tags.update(["graduation","promotion"])
154
  if any(k in s for k in ["electronics","camera","audio","headphones","gaming","computer"]):
155
+ tags.update(["holidays"])
156
  if any(k in s for k in ["book","novel","literature"]):
157
+ tags.update(["graduation"])
158
+ if any(k in s for k in ["baby","maternity","newborn","stroller"]):
159
+ tags.update(["new_baby"])
160
+ if any(k in s for k in ["wedding","engagement","bridal"]):
161
+ tags.update(["wedding"])
162
+ if any(k in s for k in ["retirement","senior gifts"]):
163
+ tags.update(["retirement"])
164
+ if any(k in s for k in ["health","wellness","get well","recovery"]):
165
+ tags.update(["get_well"])
166
  return ",".join(sorted(tags))
167
 
168
  def map_amazon_to_schema(df_raw: pd.DataFrame) -> pd.DataFrame:
 
184
  out["tags"] = out["tags"].astype(str).str.replace("|", ", ").str.lower()
185
  out["persona_fit"] = out["persona_fit"].astype(str).str.lower()
186
  out["occasion_tags"] = out["tags"].map(_infer_occasion_tags)
187
+ out["age_range"] = out["tags"].map(_infer_age_from_category).fillna("any")
188
  return out
189
 
190
  def build_doc(row: pd.Series) -> str:
191
+ return " | ".join([
192
  str(row.get("name","")),
193
  str(row.get("short_desc","")),
194
  str(row.get("tags","")),
195
  str(row.get("persona_fit","")),
196
  str(row.get("occasion_tags","")),
197
  str(row.get("age_range","")),
198
+ ])
 
199
 
200
  def load_catalog() -> pd.DataFrame:
201
  try:
 
203
  raw = ds.to_pandas()
204
  except Exception:
205
  raw = pd.DataFrame({
206
+ "Product Name": ["Wireless Earbuds","Coffee Sampler","Strategy Board Game"],
207
  "Description": [
208
  "Compact earbuds with noise isolation and long battery life.",
209
  "Four single-origin roasts from small roasters.",
 
224
  # --------------------- Business filters ---------------------
225
  def _contains_ci(series: pd.Series, needle: str) -> pd.Series:
226
  if not needle: return pd.Series(True, index=series.index)
227
+ return series.fillna("").str.contains(re.escape(needle), case=False, regex=True)
 
228
 
229
  def filter_business(df: pd.DataFrame, budget_min=None, budget_max=None,
230
+ occasion_canon: str=None, age_range: str="any") -> pd.DataFrame:
231
  m = pd.Series(True, index=df.index)
232
  if budget_min is not None:
233
  m &= df["price_usd"].fillna(0) >= float(budget_min)
234
  if budget_max is not None:
235
  m &= df["price_usd"].fillna(1e9) <= float(budget_max)
236
+ if occasion_canon:
237
+ m &= _contains_ci(df["occasion_tags"], occasion_canon)
238
  if age_range and age_range != "any":
239
  m &= (df["age_range"].fillna("any").isin([age_range, "any"]))
240
  return df[m]
 
242
  # --------------------- Embeddings + FAISS ---------------------
243
  class EmbeddingIndex:
244
  def __init__(self, docs: List[str], model_id: str):
 
245
  self.model = SentenceTransformer(model_id)
246
  embs = self.model.encode(docs, convert_to_numpy=True, normalize_embeddings=True)
247
+ self.index = faiss.IndexFlatIP(embs.shape[1]) # cosine via normalized vectors
248
  self.index.add(embs)
 
249
 
250
+ def search(self, query: str, topn: int):
251
  qv = self.model.encode([query], convert_to_numpy=True, normalize_embeddings=True)
252
  sims, idxs = self.index.search(qv, topn)
253
  return sims[0], idxs[0]
254
 
255
+ EMBED_MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2" # fast & solid on CPU
256
  EMB_INDEX = EmbeddingIndex(CATALOG["doc"].tolist(), EMBED_MODEL_ID)
257
 
258
  # --------------------- Query building ---------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  def expand_with_synonyms(tokens: List[str]) -> List[str]:
260
  out = []
261
  for t in tokens:
 
266
  return out
267
 
268
  def profile_to_query(profile: Dict) -> str:
269
+ inter = [i.lower() for i in profile.get("interests", []) if i]
270
+ expanded = expand_with_synonyms(inter)
 
271
  expanded = expanded + expanded # weight x2
272
+ rel_tokens = REL_TO_TOKENS.get(profile.get("relationship","Friend"), [])
 
 
 
 
273
  parts = []
274
  if expanded: parts.append(", ".join(expanded))
275
  if rel_tokens: parts.append(", ".join(rel_tokens))
276
+ occ = OCCASION_CANON.get(profile.get("occ_ui","Birthday"), "birthday")
277
+ parts.append(occ)
278
+ age = profile.get("age_range","any")
279
+ if age != "any": parts.append(age)
280
+ g = (profile.get("gender","any") or "any").lower()
281
+ if g != "any": parts.append("women" if g=="female" else ("men" if g=="male" else "unisex"))
282
+ return " | ".join(parts)
283
 
284
  def recommend_topk(profile: Dict, k: int=3) -> pd.DataFrame:
285
  query = profile_to_query(profile)
286
+ sims, idxs = EMBB_INDEX.search(query, topn=min(max(k*80, k), len(CATALOG))) if False else EMB_INDEX.search(query, topn=min(max(k*80, k), len(CATALOG)))
 
 
 
 
287
  df_f = filter_business(
288
  CATALOG,
289
  budget_min=profile.get("budget_min"),
290
  budget_max=profile.get("budget_max"),
291
+ occasion_canon=OCCASION_CANON.get(profile.get("occ_ui","Birthday"), "birthday"),
292
  age_range=profile.get("age_range","any"),
293
  )
294
+ if df_f.empty: df_f = CATALOG
 
295
 
296
+ # soft gender boost
297
  def gender_tokens(g: str) -> List[str]:
298
  g = (g or "any").lower()
299
+ if g == "female": return ["women","woman","female","her"]
300
+ if g == "male": return ["men","man","male","him"]
301
+ if g == "nonbinary": return ["unisex","gender neutral","they"]
302
  return ["unisex"]
303
 
304
  gts = gender_tokens(profile.get("gender","any"))
 
306
  for i, sim in zip(idxs, sims):
307
  i = int(i)
308
  if i in df_f.index:
309
+ blob = f"{CATALOG.loc[i,'tags']} {CATALOG.loc[i,'short_desc']}".lower()
310
  boost = 0.08 if any(t in blob for t in gts) else 0.0
311
  cand.append((i, float(sim) + boost))
312
  cand.sort(key=lambda x: -x[1])
313
 
 
314
  seen, picks = set(), []
315
  for gi, score in cand:
316
  nm = CATALOG.loc[gi, "name"]
 
326
 
327
  sel = [gi for gi,_ in picks]
328
  res = CATALOG.loc[sel].copy()
329
+ res["similarity"] = [dict(picks).get(int(i), np.nan) for i in sel]
 
330
  return res[["name","short_desc","price_usd","occasion_tags","persona_fit","age_range","image_url","similarity"]]
331
 
332
+ # --------------------- LLM (text) ---------------------
333
  LLM_ID = "google/flan-t5-small"
334
  try:
335
  _tok = AutoTokenizer.from_pretrained(LLM_ID)
 
339
  LLM = None
340
  print("LLM load failed, fallback to rule-based. Error:", e)
341
 
342
+ def _run_llm(prompt: str, max_new_tokens=160) -> str:
343
+ if LLM is None: return ""
 
344
  out = LLM(prompt, max_new_tokens=max_new_tokens, do_sample=False, temperature=0.0)
345
  return out[0]["generated_text"]
346
 
 
350
  except Exception:
351
  m = re.search(r"\{.*\}", s, flags=re.S)
352
  if m:
353
+ try: return json.loads(m.group(0))
354
+ except Exception: return {}
 
 
355
  return {}
356
 
357
  def llm_generate_item(profile: Dict) -> Dict:
358
  prompt = f"""
359
+ You are GIfty. Invent ONE gift that matches the catalog style with keys:
360
+ name, short_desc, price_usd, occasion_tags, persona_fit. Use JSON only.
361
  Constraints:
362
  - Fit the recipient profile and relationship.
363
+ - price_usd must be numeric within the budget range.
364
+ Profile:
365
+ name={profile.get('recipient_name','Friend')}
366
+ relationship={profile.get('relationship','Friend')}
367
+ gender={profile.get('gender','any')}
368
+ age_group={profile.get('age_range','any')}
369
+ interests={profile.get('interests',[])}
370
+ occasion={profile.get('occ_ui','Birthday')}
371
+ budget_min={profile.get('budget_min',10)}
372
+ budget_max={profile.get('budget_max',100)}
 
 
 
 
373
  """
374
  txt = _run_llm(prompt, max_new_tokens=180)
375
  data = _parse_json_maybe(txt)
376
  if not data:
377
+ core = (profile.get("interests",["hobby"])[0] or "hobby").lower()
378
  return {
379
+ "name": f"{core.title()} starter bundle ({profile.get('occ_ui','Birthday')})",
380
+ "short_desc": f"A curated set to kickstart their {core} passion.",
381
  "price_usd": float(np.clip(profile.get("budget_max", 50) or 50, 10, 300)),
382
+ "occasion_tags": OCCASION_CANON.get(profile.get("occ_ui","Birthday"), "birthday"),
383
  "persona_fit": ", ".join(profile.get("interests", [])) or "general",
384
  "age_range": profile.get("age_range","any"),
385
  "image_url": ""
 
390
  p = float(profile.get("budget_max", 50) or 50)
391
  p = float(np.clip(p, profile.get("budget_min", 10) or 10, profile.get("budget_max", 300) or 300))
392
  return {
393
+ "name": data.get("name","Gift Idea"),
394
+ "short_desc": data.get("short_desc","A thoughtful idea."),
395
  "price_usd": p,
396
+ "occasion_tags": data.get("occasion_tags", OCCASION_CANON.get(profile.get("occ_ui","Birthday"), "birthday")),
397
  "persona_fit": data.get("persona_fit", ", ".join(profile.get("interests", [])) or "general"),
398
  "age_range": profile.get("age_range","any"),
399
  "image_url": ""
 
401
 
402
  def llm_generate_message(profile: Dict) -> str:
403
  prompt = f"""
404
+ Write a short greeting (2–3 sentences) in English for a gift card.
405
+ Tone: {profile.get('tone','Heartfelt')}
406
+ Use the relationship to set warmth/formality.
407
+ Recipient: {profile.get('recipient_name','Friend')} ({profile.get('relationship','Friend')})
408
+ Occasion: {profile.get('occ_ui','Birthday')}
409
  Interests: {', '.join(profile.get('interests', []))}
410
+ Age group: {profile.get('age_range','any')}; Gender: {profile.get('gender','any')}
 
411
  Avoid emojis.
412
  """
413
  txt = _run_llm(prompt, max_new_tokens=90)
414
  if not txt:
415
  return (f"Dear {profile.get('recipient_name','Friend')}, "
416
+ f"happy {profile.get('occ_ui','Birthday').lower()}! Wishing you joy and wonderful memories.")
 
417
  return txt.strip()
418
 
419
+ # --------------------- Image generation (SD-Turbo) ---------------------
420
+ def load_image_pipeline():
421
+ try:
422
+ device = "cuda" if torch.cuda.is_available() else "cpu"
423
+ dtype = torch.float16 if torch.cuda.is_available() else torch.float32
424
+ pipe = AutoPipelineForText2Image.from_pretrained("stabilityai/sd-turbo", torch_dtype=dtype)
425
+ pipe.to(device)
426
+ return pipe
427
+ except Exception as e:
428
+ print("Image pipeline load failed:", e)
429
+ return None
430
+
431
+ IMG_PIPE = load_image_pipeline()
432
+
433
+ def generate_gift_image(gift: Dict):
434
+ if IMG_PIPE is None:
435
+ return None
436
+ prompt = (
437
+ f"{gift.get('name','gift')}, {gift.get('short_desc','')}. "
438
+ f"Style: product photo, soft studio lighting, minimal background, realistic, high detail."
439
+ )
440
+ try:
441
+ img = IMG_PIPE(
442
+ prompt,
443
+ num_inference_steps=2,
444
+ guidance_scale=0.0,
445
+ width=512, height=512
446
+ ).images[0]
447
+ return img
448
+ except Exception as e:
449
+ print("Image generation failed:", e)
450
+ return None
451
+
452
+ # --------------------- Rendering ---------------------
453
  def md_escape(text: str) -> str:
454
  return str(text).replace("|","\\|").replace("*","\\*").replace("_","\\_")
455
 
 
491
  with gr.Blocks(css=CSS) as demo:
492
  gr.Markdown(TITLE)
493
 
494
+ # top section (examples placeholder)
495
  with gr.Column(elem_id="examples"):
496
  gr.Markdown("### Quick examples")
 
 
497
 
498
  with gr.Column(elem_id="form"):
499
  with gr.Row():
500
  recipient_name = gr.Textbox(label="Recipient name", value="Noa")
501
+ relationship = gr.Dropdown(label="Relationship", choices=RECIPIENT_RELATIONSHIPS, value="Friend")
502
 
503
  with gr.Row():
504
  interests = gr.CheckboxGroup(
505
+ label="Interests (select a few)", choices=INTEREST_OPTIONS,
506
+ value=["Technology","Music"], interactive=True
 
 
507
  )
508
 
509
  with gr.Row():
510
+ occasion = gr.Dropdown(label="Occasion", choices=OCCASION_UI, value="Birthday")
511
  age = gr.Dropdown(label="Age group", choices=list(AGE_OPTIONS.keys()), value="adult (18–64)")
512
  gender = gr.Dropdown(label="Recipient gender", choices=GENDER_OPTIONS, value="any")
513
 
514
+ # Budget: try RangeSlider else two sliders
515
  RangeSlider = getattr(gr, "RangeSlider", None)
516
  if RangeSlider is not None:
517
  budget_range = RangeSlider(label="Budget range (USD)", minimum=5, maximum=500, step=1, value=[20, 60])
518
+ budget_min, budget_max = None, None
519
  else:
520
  with gr.Row():
521
  budget_min = gr.Slider(label="Min budget (USD)", minimum=5, maximum=500, step=1, value=20)
522
  budget_max = gr.Slider(label="Max budget (USD)", minimum=5, maximum=500, step=1, value=60)
523
  budget_range = gr.State(value=None)
524
 
525
+ tone = gr.Dropdown(label="Message tone", choices=MESSAGE_TONES, value="Heartfelt")
526
 
527
  go = gr.Button("Get GIfty 🎯")
528
 
529
  out_top3 = gr.HTML(label="Top-3 recommendations")
530
+ out_gen_text = gr.Markdown(label="Invented gift")
531
+ out_gen_img = gr.Image(label="Invented gift image", type="pil")
532
  out_msg = gr.Markdown(label="Personalized message")
533
 
534
+ # examples (render on top via CSS)
 
 
 
 
 
 
 
 
 
535
  if RangeSlider:
536
  example_inputs = [interests, occasion, budget_range, recipient_name, relationship, age, gender, tone]
537
+ EXAMPLES = [
538
+ [["Technology","Music"], "Birthday", [20,60], "Noa", "Friend", "adult (18–64)", "any", "Heartfelt"],
539
+ [["Home decor","Cooking"], "Housewarming", [25,45], "Daniel", "Neighbor", "adult (18–64)", "male", "Appreciative"],
540
+ [["Gaming","Photography"], "Birthday", [30,120], "Omer", "Family - Sibling", "teen (13–17)", "male", "Playful"],
541
+ [["Reading","Art"], "Graduation", [15,35], "Maya", "Romantic partner", "any", "female", "Romantic"],
542
+ ]
543
  else:
544
  example_inputs = [interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone]
545
+ EXAMPLES = [
546
+ [["Technology","Music"], "Birthday", 20, 60, "Noa", "Friend", "adult (18–64)", "any", "Heartfelt"],
547
+ [["Home decor","Cooking"], "Housewarming", 25, 45, "Daniel", "Neighbor", "adult (18–64)", "male", "Appreciative"],
548
+ [["Gaming","Photography"], "Birthday", 30, 120, "Omer", "Family - Sibling", "teen (13–17)", "male", "Playful"],
549
+ [["Reading","Art"], "Graduation", 15, 35, "Maya", "Romantic partner", "any", "female", "Romantic"],
550
+ ]
551
 
 
552
  with gr.Column(elem_id="examples"):
553
  gr.Examples(EXAMPLES, inputs=example_inputs)
554
 
555
+ # --- predict wiring ---
556
  def ui_predict(
557
+ interests_list, occasion_val,
558
+ budget_rng_or_min,
559
+ maybe_max_or_name,
560
+ maybe_name_or_rel,
561
+ rel_or_age,
562
+ age_or_gender,
563
+ gender_or_tone,
564
  tone_maybe=None
565
  ):
566
+ # Disambiguate RangeSlider vs two Sliders
567
  use_range = isinstance(budget_rng_or_min, (list, tuple))
568
  if use_range:
569
+ bmin = float(budget_rng_or_min[0]); bmax = float(budget_rng_or_min[1])
570
+ name = str(maybe_max_or_name or "Friend")
571
+ rel = str(maybe_name_or_rel or "Friend")
572
+ age_label = str(rel_or_age or "any")
 
573
  gender_val = str(age_or_gender or "any")
574
+ tone_val = str(gender_or_tone or "Heartfelt")
575
  else:
576
+ bmin = float(budget_rng_or_min if budget_rng_or_min is not None else 20)
577
+ bmax = float(maybe_max_or_name if maybe_max_or_name is not None else 60)
578
+ name = str(maybe_name_or_rel or "Friend")
579
+ rel = str(rel_or_age or "Friend")
580
+ age_label = str(age_or_gender or "any")
581
  gender_val = str(gender_or_tone or "any")
582
+ tone_val = str(tone_maybe or "Heartfelt")
583
 
584
+ if bmin > bmax: bmin, bmax = bmax, bmin
 
585
 
586
+ age_range = AGE_OPTIONS.get(age_label, "any")
587
  profile = {
588
+ "recipient_name": name,
589
+ "relationship": rel,
590
  "interests": interests_list or [],
591
+ "occ_ui": occasion_val or "Birthday",
592
+ "budget_min": bmin,
593
+ "budget_max": bmax,
594
+ "budget_usd": bmax,
595
  "age_range": age_range,
596
  "gender": gender_val or "any",
597
+ "tone": tone_val or "Heartfelt",
598
  }
599
 
600
+ # retrieval
601
  top3 = recommend_topk(profile, k=3)
602
+ top3_html = render_top3_html(top3)
603
+
604
+ # invented gift + image
605
+ gen = llm_generate_item(profile)
606
+ gen_md = f"**{md_escape(gen['name'])}**\n\n{md_escape(gen['short_desc'])}\n\n~${gen['price_usd']:.0f}"
607
+ gen_img = generate_gift_image(gen)
608
+
609
+ # greeting
610
+ msg = llm_generate_message(profile)
611
 
612
+ return top3_html, gen_md, gen_img, msg
613
 
 
614
  if RangeSlider:
615
  go.click(
616
  ui_predict,
617
  [interests, occasion, budget_range, recipient_name, relationship, age, gender, tone],
618
+ [out_top3, out_gen_text, out_gen_img, out_msg]
619
  )
620
  else:
621
  go.click(
622
  ui_predict,
623
  [interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone],
624
+ [out_top3, out_gen_text, out_gen_img, out_msg]
625
  )
626
 
627
  if __name__ == "__main__":