Danielos100 commited on
Commit
078cf80
ยท
verified ยท
1 Parent(s): 78a8d31

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +77 -33
app.py CHANGED
@@ -21,7 +21,7 @@ import torch
21
  print(f"===== Application Startup at {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S')} =====")
22
 
23
  # --------------------- Config ---------------------
24
- TITLE = "# ๐ŸŽ GIfty+ โ€” Smart Gift Recommender\n*Top-3 catalog picks + 1 DIY gift + personalized message*"
25
 
26
  DATASET_ID = os.getenv("DATASET_ID", "Danielos100/Amazon_products_clean")
27
  DATASET_SPLIT = os.getenv("DATASET_SPLIT", "train")
@@ -246,7 +246,6 @@ if "tok_set" not in CATALOG.columns:
246
  ).map(_tok_set)
247
 
248
  # ====================== Recommendations โ€” Hybrid Ranker v2 ======================
249
- # ื“ื’ืฉ ื—ื–ืง ื™ื•ืชืจ ืขืœ ืชื—ื‘ื™ื‘ื™ื; Gender/Age ืžืกื ื ื™ื ื‘ืœื‘ื“; ื—ื™ื–ื•ืง Occasion; ืจื™ืจืื ืงืจ ืื•ืคืฆื™ื•ื ืœื™; ื’ื™ื•ื•ืŸ (MMR)
250
  try:
251
  from sentence_transformers import CrossEncoder
252
  except Exception:
@@ -292,7 +291,6 @@ def expand_with_synonyms(tokens: List[str]) -> List[str]:
292
  return out
293
 
294
  def profile_to_query(profile: Dict) -> str:
295
- # ื“ื’ืฉ ร—3 ืœืชื—ื‘ื™ื‘ื™ื; ืœืœื ืื–ื›ื•ืจ ื’ื™ืœ/ืžื’ื“ืจ ื‘ื ื•ืกื— ื›ื“ื™ ืœื ืœืชืช ืžืฉืงืœ ืกืžื ื˜ื™
296
  inter = [i.lower() for i in profile.get("interests", []) if i]
297
  expanded = expand_with_synonyms(inter) * 3
298
  rel_tokens = REL_TO_TOKENS.get(profile.get("relationship","Friend"), [])
@@ -318,7 +316,6 @@ def _gender_ok_mask(gender: str) -> np.ndarray:
318
  return np.ones(len(blob), dtype=bool)
319
 
320
  def _mask_by_age(age: str, blob: pd.Series) -> np.ndarray:
321
- # ืกื™ื ื•ืŸ ื‘ืœื‘ื“ (ืœืœื ืขื•ื ืฉ/ืžืฉืงืœ)
322
  KIDS_RX = r"\b(?:kid|kids|child|children|toddler|baby|boys?|girls?|kid\'s|children\'s)\b"
323
  TEEN_RX = r"\b(?:teen|teens|young adult|ya)\b"
324
  is_kidsy = blob.str.contains(KIDS_RX, regex=True, na=False)
@@ -339,7 +336,7 @@ def _interest_bonus(profile: Dict, idx: np.ndarray) -> np.ndarray:
339
  return np.zeros(len(idx), dtype="float32")
340
  counts = np.array([len(CATALOG["tok_set"].iat[i] & vocab) for i in idx], dtype="float32")
341
  counts = np.clip(counts, 0, 6)
342
- return 0.10 * counts # ืžืฉืงืœ ื—ื–ืง ืœืชื—ื‘ื™ื‘ื™ื
343
 
344
  def _occasion_bonus(idx: np.ndarray, occ_ui: str) -> np.ndarray:
345
  slug = OCCASION_CANON.get(occ_ui or "Birthday", "birthday")
@@ -389,7 +386,6 @@ def _mmr_select(cand_idx: np.ndarray, scores: np.ndarray, k: int, lambda_: float
389
  return cand_idx[np.array(picked, dtype=int)]
390
 
391
  def recommend_top3_budget_first(profile: Dict) -> pd.DataFrame:
392
- # 1) ืกื™ื ื•ืŸ ืœืคื™ ืชืงืฆื™ื‘ + ื’ื™ืœ (ืกื™ื ื•ืŸ ื‘ืœื‘ื“) + ืžื’ื“ืจ (ืกื™ื ื•ืŸ ื‘ืœื‘ื“)
393
  lo = float(profile.get("budget_min", 0))
394
  hi = float(profile.get("budget_max", 1e9))
395
  m_price = (CATALOG["price_usd"].values >= lo) & (CATALOG["price_usd"].values <= hi)
@@ -409,7 +405,6 @@ def recommend_top3_budget_first(profile: Dict) -> pd.DataFrame:
409
  res["similarity"] = np.nan
410
  return res[["name","short_desc","price_usd","image_url","similarity"]].reset_index(drop=True)
411
 
412
- # 2) ืืžื‘ื“ื™ื ื’ + ืžื—ื™ืจ + ืชื—ื‘ื™ื‘ื™ื + Occasion
413
  q = profile_to_query(profile)
414
  qv = EMB.query_vec(q).astype("float32")
415
  X = np.asarray(EMB.embs, dtype="float32")[idx]
@@ -424,7 +419,6 @@ def recommend_top3_budget_first(profile: Dict) -> pd.DataFrame:
424
 
425
  pre_score = emb_sims + price_bonus + int_bonus + occ_bonus
426
 
427
- # 3) ืžื•ืขืžื“ื™ื
428
  K1 = min(64, idx.size)
429
  top_local = np.argpartition(-pre_score, K1-1)[:K1]
430
  cand_idx = idx[top_local]
@@ -432,9 +426,8 @@ def recommend_top3_budget_first(profile: Dict) -> pd.DataFrame:
432
  emb_norm = _minmax(emb_sims[top_local])
433
  price_norm = _minmax(price_bonus[top_local])
434
  int_norm = _minmax(int_bonus[top_local])
435
- occ_norm = _minmax(occ_bonus[top_local])
436
 
437
- # 4) ืจื™ืจืื ืงืจ ืื•ืคืฆื™ื•ื ืœื™ (ืื ืืคืฉืจ)
438
  try:
439
  from sentence_transformers import CrossEncoder as _CE
440
  ce = _load_cross_encoder()
@@ -448,7 +441,6 @@ def recommend_top3_budget_first(profile: Dict) -> pd.DataFrame:
448
  except Exception:
449
  ce_norm = np.zeros_like(emb_norm)
450
 
451
- # 5) ืฆื™ื•ืŸ ืกื•ืคื™ (ื“ื’ืฉ ื—ื–ืง ืœืชื—ื‘ื™ื‘ื™ื ื“ืจืš int_norm + ื”ืฉืื™ืœืชื”)
452
  final = (
453
  0.56 * emb_norm +
454
  0.26 * ce_norm +
@@ -457,7 +449,6 @@ def recommend_top3_budget_first(profile: Dict) -> pd.DataFrame:
457
  0.03 * price_norm
458
  ).astype("float32")
459
 
460
- # 6) ื’ื™ื•ื•ืŸ MMR ื•ื‘ื—ื™ืจืช ื˜ื•ืค-3
461
  pick_idx = _mmr_select(cand_idx, final, k=min(3, cand_idx.size), lambda_=0.7)
462
 
463
  res = CATALOG.loc[pick_idx].copy()
@@ -626,7 +617,6 @@ def diy_generate(profile: Dict) -> Tuple[dict, str]:
626
  lang = "English"
627
  ints_str = ", ".join(p["interests"]) or "general"
628
 
629
- # 1) NAME
630
  prompt_name = (
631
  f"Return ONLY a DIY gift NAME in Title Case (4โ€“8 words). "
632
  f"Must include at least one interest token from: {', '.join(sum(([it]+INTEREST_ALIASES.get(it,[]) for it in p['interests']), [])) or 'gift'}. "
@@ -641,7 +631,6 @@ def diy_generate(profile: Dict) -> Tuple[dict, str]:
641
  raw_name = _gen(tok, mdl, prompt_name, max_new_tokens=24, do_sample=False)
642
  name = _sanitize_name(raw_name, p["interests"])
643
 
644
- # 2) OVERVIEW
645
  prompt_over = (
646
  f"Write EXACTLY 2 sentences in {lang} for a handmade gift called '{name}'. "
647
  f"Mention {p['recipient_name']} ({p['relationship']}) and the occasion ({p['occ_ui']}). "
@@ -650,7 +639,6 @@ def diy_generate(profile: Dict) -> Tuple[dict, str]:
650
  )
651
  overview = _gen(tok, mdl, prompt_over, max_new_tokens=80, do_sample=True, temperature=0.9, top_p=0.95)
652
 
653
- # 3) MATERIALS
654
  prompt_mat = (
655
  f"List 6 concise materials with quantities to make '{name}' cheaply. "
656
  f"Keep total within {p['budget_min']}-{p['budget_max']} USD. "
@@ -659,7 +647,6 @@ def diy_generate(profile: Dict) -> Tuple[dict, str]:
659
  mats_txt = _gen(tok, mdl, prompt_mat, max_new_tokens=96, do_sample=False)
660
  materials = _split_list_text(mats_txt, [",", ";"])
661
 
662
- # 4) STEPS
663
  prompt_steps = (
664
  f"Write 6 short imperative steps to make '{name}'. "
665
  "Output ONLY a semicolon-separated list."
@@ -667,14 +654,12 @@ def diy_generate(profile: Dict) -> Tuple[dict, str]:
667
  steps_txt = _gen(tok, mdl, prompt_steps, max_new_tokens=120, do_sample=True, temperature=0.9, top_p=0.95)
668
  steps = _split_list_text(steps_txt, [";", "\n"])
669
 
670
- # 5) COST
671
  prompt_cost = (
672
  f"Return ONE integer total cost in USD between {p['budget_min']}-{p['budget_max']}. Output NUMBER only."
673
  )
674
  cost_txt = _gen(tok, mdl, prompt_cost, max_new_tokens=6, do_sample=False)
675
  cost = _only_int(cost_txt)
676
 
677
- # 6) MINUTES
678
  time_txt = _gen(tok, mdl, "Return ONE integer minutes between 20 and 180. Output NUMBER only.",
679
  max_new_tokens=6, do_sample=False)
680
  minutes = _only_int(time_txt)
@@ -690,7 +675,6 @@ def diy_generate(profile: Dict) -> Tuple[dict, str]:
690
  return idea, "ok"
691
 
692
  # --------------------- Personalized Message (FLAN, ืžื’ื•ื•ืŸ + ื•ืœื™ื“ืฆื™ื”) ---------------------
693
- # ืžื‘ื•ืกืก ืื—ื“-ืœืื—ื“ ืขืœ ื”ืงื•ื“ ืžื”ืงื•ืœืื‘ ืฉืœืš, ืžื•ืชืื ืœืฉื™ืžื•ืฉ ื™ืฉื™ืจ ื‘ืืคืœื™ืงืฆื™ื”
694
  MSG_MODEL_ID = "google/flan-t5-small"
695
  MSG_DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
696
  TEMP_RANGE = (0.88, 1.10)
@@ -721,9 +705,9 @@ TONE_STYLES: Dict[str, Dict[str, List[str]]] = {
721
  "Funny": {
722
  "system": "Write 2โ€“3 witty sentences with playful humor.",
723
  "rules": [
724
- "Add one subtle pun linked to the occasion or interests.",
725
- "No slapstick; keep it tasteful.",
726
- "End with a cheeky nudge."
727
  ],
728
  },
729
  "Heartfelt": {
@@ -951,6 +935,24 @@ with gr.Blocks(
951
  /* ื”ืกืชืจืช ืžืกื’ืจืช/ื”ื™ื™ืœื™ื™ื˜ ื›ืชื•ืžื™ื ืฉืœ ื˜ื‘ืœืช ื”ื“ื•ื’ืžืื•ืช */
952
  .handsontable .wtBorder, .handsontable .htBorders, .handsontable .wtBorder.current { display: none !important; }
953
  .gr-dataframe table td:focus { outline: none !important; box-shadow: none !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
954
  """
955
  ) as demo:
956
  gr.Markdown(TITLE)
@@ -991,7 +993,7 @@ with gr.Blocks(
991
  tone = gr.Dropdown(label="Message tone", choices=MESSAGE_TONES, value="Funny")
992
 
993
  # Action button and outputs
994
- go = gr.Button("Get GIfty")
995
  gr.Markdown("### ๐ŸŽฏ Recommendations")
996
  out_top3 = gr.HTML()
997
  gr.Markdown("### ๐Ÿ› ๏ธ DIY Gift")
@@ -999,6 +1001,9 @@ with gr.Blocks(
999
  gr.Markdown("### ๐Ÿ’Œ Personalized Message")
1000
  out_msg = gr.Markdown()
1001
 
 
 
 
1002
  # ---- row click handler (fill form) ----
1003
  def _on_example_select(evt: gr.SelectData):
1004
  r = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index
@@ -1016,7 +1021,7 @@ with gr.Blocks(
1016
  outputs=[interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone]
1017
  )
1018
 
1019
- # ---- UI predict ----
1020
  def render_diy_md(j: dict) -> str:
1021
  if not j: return "_DIY generation failed._"
1022
  steps = j.get('step_by_step_instructions', j.get('steps', []))
@@ -1035,14 +1040,14 @@ with gr.Blocks(
1035
  ]
1036
  return "\n".join(parts)
1037
 
1038
- def ui_predict(interests_list, occasion_val, bmin, bmax, name, rel, age_label, gender_val, tone_val):
 
1039
  try:
1040
  bmin = float(bmin); bmax = float(bmax)
1041
  except Exception:
1042
  bmin, bmax = 5.0, 500.0
1043
  if bmin > bmax: bmin, bmax = bmax, bmin
1044
-
1045
- profile = {
1046
  "recipient_name": name or "Friend",
1047
  "relationship": rel or "Friend",
1048
  "interests": interests_list or [],
@@ -1054,21 +1059,60 @@ with gr.Blocks(
1054
  "tone": tone_val or "Heartfelt",
1055
  }
1056
 
 
 
 
 
 
 
 
 
 
1057
  top3 = recommend_top3_budget_first(profile)
1058
  top3_html = render_top3_html(top3, age_label)
 
1059
 
 
 
 
1060
  diy_json, _status = diy_generate(profile)
1061
  diy_md = render_diy_md(diy_json)
 
1062
 
 
 
 
1063
  msg_obj = generate_personal_message(profile)
1064
  msg = msg_obj["message"]
 
 
 
 
 
 
 
 
 
 
1065
 
1066
- return top3_html, diy_md, msg
1067
-
1068
- go.click(
1069
- ui_predict,
1070
- [interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone],
1071
- [out_top3, out_diy_md, out_msg]
 
 
 
 
 
 
 
 
 
 
 
 
1072
  )
1073
 
1074
  if __name__ == "__main__":
 
21
  print(f"===== Application Startup at {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S')} =====")
22
 
23
  # --------------------- Config ---------------------
24
+ TITLE = "# ๐ŸŽ GIfty+ Smart Gift Recommender\n*Top-3 catalog picks + 1 DIY gift + personalized message*"
25
 
26
  DATASET_ID = os.getenv("DATASET_ID", "Danielos100/Amazon_products_clean")
27
  DATASET_SPLIT = os.getenv("DATASET_SPLIT", "train")
 
246
  ).map(_tok_set)
247
 
248
  # ====================== Recommendations โ€” Hybrid Ranker v2 ======================
 
249
  try:
250
  from sentence_transformers import CrossEncoder
251
  except Exception:
 
291
  return out
292
 
293
  def profile_to_query(profile: Dict) -> str:
 
294
  inter = [i.lower() for i in profile.get("interests", []) if i]
295
  expanded = expand_with_synonyms(inter) * 3
296
  rel_tokens = REL_TO_TOKENS.get(profile.get("relationship","Friend"), [])
 
316
  return np.ones(len(blob), dtype=bool)
317
 
318
  def _mask_by_age(age: str, blob: pd.Series) -> np.ndarray:
 
319
  KIDS_RX = r"\b(?:kid|kids|child|children|toddler|baby|boys?|girls?|kid\'s|children\'s)\b"
320
  TEEN_RX = r"\b(?:teen|teens|young adult|ya)\b"
321
  is_kidsy = blob.str.contains(KIDS_RX, regex=True, na=False)
 
336
  return np.zeros(len(idx), dtype="float32")
337
  counts = np.array([len(CATALOG["tok_set"].iat[i] & vocab) for i in idx], dtype="float32")
338
  counts = np.clip(counts, 0, 6)
339
+ return 0.10 * counts
340
 
341
  def _occasion_bonus(idx: np.ndarray, occ_ui: str) -> np.ndarray:
342
  slug = OCCASION_CANON.get(occ_ui or "Birthday", "birthday")
 
386
  return cand_idx[np.array(picked, dtype=int)]
387
 
388
  def recommend_top3_budget_first(profile: Dict) -> pd.DataFrame:
 
389
  lo = float(profile.get("budget_min", 0))
390
  hi = float(profile.get("budget_max", 1e9))
391
  m_price = (CATALOG["price_usd"].values >= lo) & (CATALOG["price_usd"].values <= hi)
 
405
  res["similarity"] = np.nan
406
  return res[["name","short_desc","price_usd","image_url","similarity"]].reset_index(drop=True)
407
 
 
408
  q = profile_to_query(profile)
409
  qv = EMB.query_vec(q).astype("float32")
410
  X = np.asarray(EMB.embs, dtype="float32")[idx]
 
419
 
420
  pre_score = emb_sims + price_bonus + int_bonus + occ_bonus
421
 
 
422
  K1 = min(64, idx.size)
423
  top_local = np.argpartition(-pre_score, K1-1)[:K1]
424
  cand_idx = idx[top_local]
 
426
  emb_norm = _minmax(emb_sims[top_local])
427
  price_norm = _minmax(price_bonus[top_local])
428
  int_norm = _minmax(int_bonus[top_local])
429
+ occ_norm = _minmax(oc_bonus := occ_bonus[top_local])
430
 
 
431
  try:
432
  from sentence_transformers import CrossEncoder as _CE
433
  ce = _load_cross_encoder()
 
441
  except Exception:
442
  ce_norm = np.zeros_like(emb_norm)
443
 
 
444
  final = (
445
  0.56 * emb_norm +
446
  0.26 * ce_norm +
 
449
  0.03 * price_norm
450
  ).astype("float32")
451
 
 
452
  pick_idx = _mmr_select(cand_idx, final, k=min(3, cand_idx.size), lambda_=0.7)
453
 
454
  res = CATALOG.loc[pick_idx].copy()
 
617
  lang = "English"
618
  ints_str = ", ".join(p["interests"]) or "general"
619
 
 
620
  prompt_name = (
621
  f"Return ONLY a DIY gift NAME in Title Case (4โ€“8 words). "
622
  f"Must include at least one interest token from: {', '.join(sum(([it]+INTEREST_ALIASES.get(it,[]) for it in p['interests']), [])) or 'gift'}. "
 
631
  raw_name = _gen(tok, mdl, prompt_name, max_new_tokens=24, do_sample=False)
632
  name = _sanitize_name(raw_name, p["interests"])
633
 
 
634
  prompt_over = (
635
  f"Write EXACTLY 2 sentences in {lang} for a handmade gift called '{name}'. "
636
  f"Mention {p['recipient_name']} ({p['relationship']}) and the occasion ({p['occ_ui']}). "
 
639
  )
640
  overview = _gen(tok, mdl, prompt_over, max_new_tokens=80, do_sample=True, temperature=0.9, top_p=0.95)
641
 
 
642
  prompt_mat = (
643
  f"List 6 concise materials with quantities to make '{name}' cheaply. "
644
  f"Keep total within {p['budget_min']}-{p['budget_max']} USD. "
 
647
  mats_txt = _gen(tok, mdl, prompt_mat, max_new_tokens=96, do_sample=False)
648
  materials = _split_list_text(mats_txt, [",", ";"])
649
 
 
650
  prompt_steps = (
651
  f"Write 6 short imperative steps to make '{name}'. "
652
  "Output ONLY a semicolon-separated list."
 
654
  steps_txt = _gen(tok, mdl, prompt_steps, max_new_tokens=120, do_sample=True, temperature=0.9, top_p=0.95)
655
  steps = _split_list_text(steps_txt, [";", "\n"])
656
 
 
657
  prompt_cost = (
658
  f"Return ONE integer total cost in USD between {p['budget_min']}-{p['budget_max']}. Output NUMBER only."
659
  )
660
  cost_txt = _gen(tok, mdl, prompt_cost, max_new_tokens=6, do_sample=False)
661
  cost = _only_int(cost_txt)
662
 
 
663
  time_txt = _gen(tok, mdl, "Return ONE integer minutes between 20 and 180. Output NUMBER only.",
664
  max_new_tokens=6, do_sample=False)
665
  minutes = _only_int(time_txt)
 
675
  return idea, "ok"
676
 
677
  # --------------------- Personalized Message (FLAN, ืžื’ื•ื•ืŸ + ื•ืœื™ื“ืฆื™ื”) ---------------------
 
678
  MSG_MODEL_ID = "google/flan-t5-small"
679
  MSG_DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
680
  TEMP_RANGE = (0.88, 1.10)
 
705
  "Funny": {
706
  "system": "Write 2โ€“3 witty sentences with playful humor.",
707
  "rules": [
708
+ "Add one subtle pun linked to the occasion or interests.",
709
+ "No slapstick; keep it tasteful.",
710
+ "End with a cheeky nudge."
711
  ],
712
  },
713
  "Heartfelt": {
 
935
  /* ื”ืกืชืจืช ืžืกื’ืจืช/ื”ื™ื™ืœื™ื™ื˜ ื›ืชื•ืžื™ื ืฉืœ ื˜ื‘ืœืช ื”ื“ื•ื’ืžืื•ืช */
936
  .handsontable .wtBorder, .handsontable .htBorders, .handsontable .wtBorder.current { display: none !important; }
937
  .gr-dataframe table td:focus { outline: none !important; box-shadow: none !important; }
938
+
939
+ /* === ื”ื•ืคืš ื›ืœ ืฉื•ืจืช ื“ื•ื’ืžื” ืœ"ื›ืคืชื•ืจ" === */
940
+ .gr-dataframe thead { display:none; }
941
+ .gr-dataframe table { border-collapse: separate !important; border-spacing: 0 8px !important; }
942
+ .gr-dataframe tbody tr {
943
+ cursor: pointer;
944
+ background: #fff;
945
+ border-radius: 12px;
946
+ box-shadow: 0 1px 0 rgba(0,0,0,.06);
947
+ }
948
+ .gr-dataframe tbody tr td {
949
+ border-top: 1px solid #eee !important;
950
+ border-bottom: 1px solid #eee !important;
951
+ }
952
+ .gr-dataframe tbody tr:hover {
953
+ background: #f7fafc;
954
+ box-shadow: 0 0 0 2px #e2e8f0 inset;
955
+ }
956
  """
957
  ) as demo:
958
  gr.Markdown(TITLE)
 
993
  tone = gr.Dropdown(label="Message tone", choices=MESSAGE_TONES, value="Funny")
994
 
995
  # Action button and outputs
996
+ go = gr.Button("Get GIfty!")
997
  gr.Markdown("### ๐ŸŽฏ Recommendations")
998
  out_top3 = gr.HTML()
999
  gr.Markdown("### ๐Ÿ› ๏ธ DIY Gift")
 
1001
  gr.Markdown("### ๐Ÿ’Œ Personalized Message")
1002
  out_msg = gr.Markdown()
1003
 
1004
+ # --- State: run token (to "override" previous runs safely) ---
1005
+ run_token = gr.State(0)
1006
+
1007
  # ---- row click handler (fill form) ----
1008
  def _on_example_select(evt: gr.SelectData):
1009
  r = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index
 
1021
  outputs=[interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone]
1022
  )
1023
 
1024
+ # ---- helper: render DIY markdown (unchanged logic) ----
1025
  def render_diy_md(j: dict) -> str:
1026
  if not j: return "_DIY generation failed._"
1027
  steps = j.get('step_by_step_instructions', j.get('steps', []))
 
1040
  ]
1041
  return "\n".join(parts)
1042
 
1043
+ # ---- Build profile dict (shared) ----
1044
+ def _build_profile(interests_list, occasion_val, bmin, bmax, name, rel, age_label, gender_val, tone_val):
1045
  try:
1046
  bmin = float(bmin); bmax = float(bmax)
1047
  except Exception:
1048
  bmin, bmax = 5.0, 500.0
1049
  if bmin > bmax: bmin, bmax = bmax, bmin
1050
+ return {
 
1051
  "recipient_name": name or "Friend",
1052
  "relationship": rel or "Friend",
1053
  "interests": interests_list or [],
 
1059
  "tone": tone_val or "Heartfelt",
1060
  }
1061
 
1062
+ # ---- NEW: split into 3 functions (partial results) + token check ----
1063
+ def start_run(curr_token):
1064
+ # increments token (very fast; allows immediate second click)
1065
+ return int(curr_token or 0) + 1
1066
+
1067
+ def predict_recs_only(rt, interests_list, occasion_val, bmin, bmax, name, rel, age_label, gender_val, tone_val):
1068
+ # compute only recommendations; ignore if token is stale
1069
+ latest = rt
1070
+ profile = _build_profile(interests_list, occasion_val, bmin, bmax, name, rel, age_label, gender_val, tone_val)
1071
  top3 = recommend_top3_budget_first(profile)
1072
  top3_html = render_top3_html(top3, age_label)
1073
+ return gr.update(value=top3_html, visible=True), latest
1074
 
1075
+ def predict_diy_only(rt, interests_list, occasion_val, bmin, bmax, name, rel, age_label, gender_val, tone_val):
1076
+ latest = rt
1077
+ profile = _build_profile(interests_list, occasion_val, bmin, bmax, name, rel, age_label, gender_val, tone_val)
1078
  diy_json, _status = diy_generate(profile)
1079
  diy_md = render_diy_md(diy_json)
1080
+ return gr.update(value=diy_md, visible=True), latest
1081
 
1082
+ def predict_msg_only(rt, interests_list, occasion_val, bmin, bmax, name, rel, age_label, gender_val, tone_val):
1083
+ latest = rt
1084
+ profile = _build_profile(interests_list, occasion_val, bmin, bmax, name, rel, age_label, gender_val, tone_val)
1085
  msg_obj = generate_personal_message(profile)
1086
  msg = msg_obj["message"]
1087
+ return gr.update(value=msg, visible=True), latest
1088
+
1089
+ # --- Wire events: one short "start", then 3 parallel tasks that each update its output ASAP ---
1090
+ # Start: bump token
1091
+ ev_start = go.click(
1092
+ start_run,
1093
+ inputs=[run_token],
1094
+ outputs=[run_token],
1095
+ queue=True,
1096
+ )
1097
 
1098
+ # Run three tasks in parallel (each returns its output + echoes token to keep it "fresh")
1099
+ ev_rec = ev_start.then(
1100
+ predict_recs_only,
1101
+ inputs=[run_token, interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone],
1102
+ outputs=[out_top3, run_token],
1103
+ queue=True,
1104
+ )
1105
+ ev_diy = ev_start.then(
1106
+ predict_diy_only,
1107
+ inputs=[run_token, interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone],
1108
+ outputs=[out_diy_md, run_token],
1109
+ queue=True,
1110
+ )
1111
+ ev_msg = ev_start.then(
1112
+ predict_msg_only,
1113
+ inputs=[run_token, interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone],
1114
+ outputs=[out_msg, run_token],
1115
+ queue=True,
1116
  )
1117
 
1118
  if __name__ == "__main__":