# app.py โ€” GIftyPlus (lean) # ----------------------------------------------------------------------------- # High-level overview # ----------------------------------------------------------------------------- # GIftyPlus is a lightweight gift recommender + DIY generator. # Pipeline: # 1) Load & normalize an Amazon-like product dataset (name/desc/tags/price/img). # 2) Build sentence embeddings for semantic retrieval (cached to .npy). # 3) Rank items with a weighted score (embeddings + optional cross-encoder + # interest/occasion/price bonuses) and diversify with MMR. # 4) Generate a DIY gift idea (FLAN-T5), then embed 10 candidates and append # the best one as a "Generated" #4 result. # 5) Generate a short personalized message (FLAN-T5) with basic validators. # 6) Gradio UI: input form, input summary, top-3 + generated #4, DIY section, # and personalized message section. # # Env vars you can override: # DATASET_ID, DATASET_SPLIT, MAX_ROWS, # EMBED_MODEL_ID, RERANK_MODEL_ID, # DIY_MODEL_ID, MAX_INPUT_TOKENS, DIY_MAX_NEW_TOKENS. # ----------------------------------------------------------------------------- import os, re, json, hashlib, pathlib, random from typing import Dict, List, Tuple, Optional, Any import numpy as np, pandas as pd, gradio as gr, torch from datasets import load_dataset from sentence_transformers import SentenceTransformer from transformers import AutoTokenizer, AutoModelForSeq2SeqLM TITLE = "# ๐ŸŽ GIftyPlus - Smart Gift Recommender\n*Top-3 catalog picks + 1 DIY gift + personalized message*" DATASET_ID = os.getenv("DATASET_ID", "Danielos100/Amazon_products_clean") DATASET_SPLIT = os.getenv("DATASET_SPLIT", "train") MAX_ROWS = int(os.getenv("MAX_ROWS", "12000")) EMBED_MODEL_ID = os.getenv("EMBED_MODEL_ID", "sentence-transformers/all-MiniLM-L12-v2") def resolve_cache_dir(): # Choose the first writable cache directory: # 1) EMBED_CACHE_DIR env, 2) project .gifty_cache, 3) /tmp/.gifty_cache for p in [os.getenv("EMBED_CACHE_DIR"), os.path.join(os.getcwd(), ".gifty_cache"), "/tmp/.gifty_cache"]: if not p: continue pathlib.Path(p).mkdir(parents=True, exist_ok=True) with open(os.path.join(p, ".write_test"), "w") as f: f.write("ok") pathlib.Path(os.path.join(p, ".write_test")).unlink(missing_ok=True) return p return os.getcwd() EMBED_CACHE_DIR = resolve_cache_dir() # UI vocab / options INTEREST_OPTIONS = ["Sports","Travel","Cooking","Technology","Music","Art","Reading","Gardening","Fashion","Gaming","Photography","Hiking","Movies","Crafts","Pets","Wellness","Collecting","Food","Home decor","Science"] OCCASION_UI = ["Birthday","Wedding / Engagement","Anniversary","Graduation","New baby","Housewarming","Retirement","Holidays","Valentineโ€™s Day","Promotion / New job","Get well soon"] OCCASION_CANON = {"Birthday":"birthday","Wedding / Engagement":"wedding","Anniversary":"anniversary","Graduation":"graduation","New baby":"new_baby","Housewarming":"housewarming","Retirement":"retirement","Holidays":"holidays","Valentineโ€™s Day":"valentines","Promotion / New job":"promotion","Get well soon":"get_well"} RECIPIENT_RELATIONSHIPS = ["Family - Parent","Family - Sibling","Family - Child","Family - Other relative","Friend","Colleague","Boss","Romantic partner","Teacher / Mentor","Neighbor","Client / Business partner"] MESSAGE_TONES = ["Formal","Casual","Funny","Heartfelt","Inspirational","Playful","Romantic","Appreciative","Encouraging"] AGE_OPTIONS = {"any":"any","kid (3โ€“12)":"kids","teen (13โ€“17)":"teens","adult (18โ€“64)":"adult","senior (65+)":"senior"} GENDER_OPTIONS = ["any","female","male","nonbinary"] # Light synonym expansion for interests; used to enrich queries and "hit" checks SYNONYMS = {"sports":["fitness","outdoor","training","yoga","run"],"travel":["luggage","passport","map","trip","vacation"],"cooking":["kitchen","cookware","chef","baking"],"technology":["electronics","gadgets","device","smart","computer"],"music":["audio","headphones","earbuds","speaker","vinyl"],"art":["painting","drawing","sketch","canvas"],"reading":["book","novel","literature"],"gardening":["plants","planter","seeds","garden","indoor"],"fashion":["style","accessory","jewelry"],"gaming":["board game","puzzle","video game","controller"],"photography":["camera","lens","tripod","film"],"hiking":["outdoor","camping","backpack","trek"],"movies":["film","cinema","blu-ray","poster"],"crafts":["diy","handmade","kit","knitting"],"pets":["dog","cat","pet"],"wellness":["relaxation","spa","aromatherapy","self-care"],"collecting":["display","collector","limited edition"],"food":["gourmet","snack","treats","chocolate"],"home decor":["home","decor","wall art","candle"],"science":["lab","experiment","STEM","microscope"]} REL_TO_TOKENS = {"Family - Parent":["parent","family"],"Family - Sibling":["sibling","family"],"Family - Child":["kids","play","family"],"Family - Other relative":["family","relative"],"Friend":["friendly"],"Colleague":["office","work","professional"],"Boss":["executive","professional","premium"],"Romantic partner":["romantic","couple"],"Teacher / Mentor":["teacher","mentor","thank_you"],"Neighbor":["neighbor","housewarming"],"Client / Business partner":["professional","thank_you","premium"]} # --- Price parsing helpers (robust to currency symbols and ranges) --- _CURRENCY_RE = re.compile(r"[^\d.,\-]+"); _NUM_RE = re.compile(r"(\d+(?:[.,]\d+)?)"); _RANGE_SEP = re.compile(r"\s*(?:-|โ€“|โ€”|to)\s*") def _to_price_usd(x): if pd.isna(x): return np.nan s = str(x).strip().lower() if _RANGE_SEP.search(s): s = _RANGE_SEP.split(s)[0] s = _CURRENCY_RE.sub(" ", s); m = _NUM_RE.search(s.replace(",", ".")) return float(m.group(1)) if m else np.nan def _first_present(df, cands): # Return the first column name that exists in df out of candidates (case-insensitive) lower = {c.lower(): c for c in df.columns} for c in cands: if c in df.columns: return c if c.lower() in lower: return lower[c.lower()] return None def _auto_price_col(df): # Heuristics for price column detection when column name is unknown for c in df.columns: s = df[c] if pd.api.types.is_numeric_dtype(s) and not s.dropna().empty and (s.dropna().between(0.5, 10000)).mean() > .6: return c for c in df.columns: if df[c].astype(str).head(200).str.lower().str.contains(r"\$|โ‚ช|eur|usd|ยฃ|โ‚ฌ|\d").mean() > .5: return c return None def map_amazon_to_schema(raw: pd.DataFrame) -> pd.DataFrame: # Map arbitrary Amazon-like columns into a compact schema suitable for retrieval name_c=_first_present(raw,["product name","title","name","product_title"]); desc_c=_first_present(raw,["description","product_description","feature","about"]) cat_c=_first_present(raw,["category","categories","main_cat","product_category"]); price_c=_first_present(raw,["selling price","price","current_price","list_price","price_amount","actual_price","price_usd"]) or _auto_price_col(raw) img_c=_first_present(raw,["image","image_url","imageurl","imUrl","img","img_url"]) df=pd.DataFrame({"name":raw.get(name_c,""),"short_desc":raw.get(desc_c,""),"tags":raw.get(cat_c,""),"price_usd":raw.get(price_c,np.nan),"image_url":raw.get(img_c,"")}) # Light normalization / truncation to keep UI compact df["price_usd"]=df["price_usd"].map(_to_price_usd); df["name"]=df["name"].astype(str).str.strip().str.slice(0,160) df["short_desc"]=df["short_desc"].astype(str).str.strip().str.slice(0,600); df["tags"]=df["tags"].astype(str).str.replace("|",", ").str.lower() return df def extract_top_cat(tags:str)->str: # Extract a "top-level" category token for quick grouping/labeling s=(tags or "").lower() for sep in ["|",">"]: if sep in s: return s.split(sep,1)[0].strip() return s.strip().split(",")[0] if s else "" def load_catalog()->pd.DataFrame: # Load dataset โ†’ normalize schema โ†’ filter โ†’ light feature engineering df=map_amazon_to_schema(load_dataset(DATASET_ID, split=DATASET_SPLIT).to_pandas()).drop_duplicates(subset=["name","short_desc"]) df=df[pd.notna(df["price_usd"])]; df=df[(df["price_usd"]>0)&(df["price_usd"]<=500)].reset_index(drop=True) if len(df)>MAX_ROWS: df=df.sample(n=MAX_ROWS,random_state=42).reset_index(drop=True) df["doc"]=(df["name"].fillna("")+" | "+df["tags"].fillna("")+" | "+df["short_desc"].fillna("")).str.strip() df["top_cat"]=df["tags"].map(extract_top_cat) df["blob"]=(df["name"].fillna("")+" "+df["tags"].fillna("")+" "+df["short_desc"].fillna("")).str.lower() return df CATALOG=load_catalog() # ----------------------------------------------------------------------------- # Embedding bank with on-disk caching # ----------------------------------------------------------------------------- class EmbeddingBank: def __init__(s, docs, model_id, dataset_tag): s.model_id=model_id; s.dataset_tag=dataset_tag; s.model=SentenceTransformer(model_id); s.embs=s._load_or_build(docs) def _cache_path(s,n): return os.path.join(EMBED_CACHE_DIR, f"emb_{hashlib.md5((s.dataset_tag+'|'+s.model_id+f'|{n}').encode()).hexdigest()[:10]}.npy") def _load_or_build(s,docs): p=s._cache_path(len(docs)) if os.path.exists(p): embs=np.load(p,mmap_mode="r"); if embs.shape[0]==len(docs): return embs embs=s.model.encode(docs, convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=True) np.save(p, embs); return np.load(p, mmap_mode="r") def query_vec(s,text): return s.model.encode([text], convert_to_numpy=True, normalize_embeddings=True)[0] EMB=EmbeddingBank(CATALOG["doc"].tolist(), EMBED_MODEL_ID, DATASET_ID) # Token set for light lexical checks (used by interest Hit@k) _tok_rx = re.compile(r"[a-z0-9][a-z0-9\-']*") if "tok_set" not in CATALOG.columns: CATALOG["tok_set"]=(CATALOG["name"].fillna("")+" "+CATALOG["tags"].fillna("")+" "+CATALOG["short_desc"].fillna("")).map(lambda t:set(_tok_rx.findall(str(t).lower()))) # Optional cross-encoder for re-ranking (small CPU-friendly model by default) try: from sentence_transformers import CrossEncoder except: CrossEncoder=None RERANK_MODEL_ID=os.getenv("RERANK_MODEL_ID","cross-encoder/ms-marco-MiniLM-L-6-v2") _CE_MODEL=None def _load_cross_encoder(): global _CE_MODEL if _CE_MODEL is None and CrossEncoder is not None: _CE_MODEL=CrossEncoder(RERANK_MODEL_ID, device="cpu") return _CE_MODEL # Occasion-specific keyword priors (light bonus shaping) OCCASION_PRIORS={"valentines":[("jewelry",.12),("chocolate",.10),("candle",.08),("romantic",.08),("couple",.08),("heart",.06)], "birthday":[("fun",.06),("game",.06),("personalized",.06),("gift set",.05),("surprise",.04)], "anniversary":[("couple",.10),("jewelry",.10),("photo",.08),("frame",.06),("memory",.06),("candle",.06)], "graduation":[("journal",.10),("planner",.08),("office",.08),("coffee",.06),("motivation",.06)], "housewarming":[("home",.10),("kitchen",.08),("decor",.10),("candle",.06),("serving",.06)], "new_baby":[("baby",.12),("nursery",.10),("soft",.06),("blanket",.06)], "retirement":[("relax",.08),("hobby",.08),("travel",.06),("book",.06)], "holidays":[("holiday",.10),("winter",.08),("chocolate",.08),("cozy",.06),("family",.06)], "promotion":[("desk",.10),("office",.10),("premium",.08),("organizer",.06)], "get_well":[("cozy",.10),("tea",.08),("soothing",.06),("care",.06)]} def expand_with_synonyms(tokens: List[str])->List[str]: # Expand user-provided interests with synonyms to enrich the query out=[]; for t in tokens: t=t.strip().lower() if t: out+=[t]+SYNONYMS.get(t,[]) return out def profile_to_query(p:Dict)->str: # Construct a dense query string from profile information inter=[i.lower() for i in p.get("interests",[]) if i]; expanded=expand_with_synonyms(inter)*3 parts=[", ".join(expanded) if expanded else "", ", ".join(REL_TO_TOKENS.get(p.get("relationship","Friend"),[])), OCCASION_CANON.get(p.get("occ_ui","Birthday"),"birthday")] tail=f"gift ideas for a {p.get('relationship','Friend')} for {parts[-1]}; likes {', '.join(inter) or 'general'}" return " | ".join([x for x in parts if x])+" | "+tail def _gender_ok_mask(g:str)->np.ndarray: # Gender-aware filter: exclude items explicitly labeled for the opposite gender unless unisex g=(g or "any").lower(); bl=CATALOG["blob"] has_m=bl.str.contains(r"\b(men|man's|mens|male|for men)\b",regex=True,na=False) has_f=bl.str.contains(r"\b(women|woman's|womens|female|for women|dress)\b",regex=True,na=False) has_u=bl.str.contains(r"\bunisex|gender neutral\b",regex=True,na=False) if g=="female": return (~has_m | has_u).to_numpy() if g=="male": return (~has_f | has_u).to_numpy() return np.ones(len(bl),bool) def _mask_by_age(age:str, blob:pd.Series)->np.ndarray: # Age-aware filter: crude regex to separate kids/teens/adults kids=blob.str.contains(r"\b(?:kid|kids|child|children|toddler|baby|boys?|girls?|kid's|children's)\b",regex=True,na=False) teen=blob.str.contains(r"\b(?:teen|teens|young adult|ya)\b",regex=True,na=False) if age in ("adult","senior"): return (~kids).to_numpy() if age=="teens": return ((~kids)|teen).to_numpy() if age=="kids": return (kids | (~teen & kids)).to_numpy() return np.ones(len(blob),bool) def _interest_bonus(p:Dict, idx:np.ndarray)->np.ndarray: # Soft bonus if catalog tokens overlap with interest vocabulary (synonyms included) ints=[i.lower() for i in p.get("interests",[]) if i]; syns=[s for it in ints for s in SYNONYMS.get(it,[])]; vocab=set(ints+syns) if not vocab or idx.size==0: return np.zeros(len(idx),"float32") counts=np.array([len(CATALOG["tok_set"].iat[i] & vocab) for i in idx],"float32"); return .10*np.clip(counts,0,6) def _occasion_bonus(idx:np.ndarray, occ_ui:str)->np.ndarray: # Soft bonus based on occasion priors (keywords found in item blob) pri=OCCASION_PRIORS.get(OCCASION_CANON.get(occ_ui or "Birthday","birthday"),[]) if not pri or idx.size==0: return np.zeros(len(idx),"float32") bl=CATALOG["blob"].to_numpy(); out=np.zeros(len(idx),"float32") for j,i in enumerate(idx): bonus=sum(w for kw,w in pri if kw in bl[i]); out[j]=min(bonus,.15) return out def _minmax(x:np.ndarray)->np.ndarray: # Normalize to [0,1] with safe guard for constant vectors if x.size==0: return x lo,hi=float(np.min(x)),float(np.max(x)); return np.zeros_like(x) if hi<=lo+1e-9 else (x-lo)/(hi-lo) def _mmr_select(cand_idx:np.ndarray, scores:np.ndarray, k:int, lambda_:float=.7)->np.ndarray: # MMR selection to maintain diversity in the final top-k if cand_idx.size<=k: return cand_idx[np.argsort(-scores)][:k] picked=[]; rest=list(range(len(cand_idx))); rel=_minmax(scores) V=np.asarray(EMB.embs,"float32")[cand_idx]; V/=np.linalg.norm(V,axis=1,keepdims=True)+1e-8 while len(picked) pd.DataFrame: """ Retrieve โ†’ score โ†’ diversify. Always returns semantically-ranked results from the catalog (no โ€œcheapest-3โ€ fallback). If strict filters empty the pool, we progressively relax them but still rank by embeddings + bonuses. Optionally appends a 4th 'Generated' item (DIY) when include_synth=True. """ # ---------- Filters (progressive relaxations) ---------- lo, hi = float(p.get("budget_min", 0)), float(p.get("budget_max", 1e9)) blob = CATALOG["blob"] price = CATALOG["price_usd"].values age_ok = _mask_by_age(p.get("age_range", "any"), blob) gen_ok = _gender_ok_mask(p.get("gender", "any")) price_ok_strict = (price >= lo) & (price <= hi) price_ok_wide = (price >= max(0, lo * (1 - widen_budget_frac))) & \ (price <= (hi * (1 + widen_budget_frac) if hi < 1e8 else hi)) mask_chain = [ price_ok_strict & age_ok & gen_ok, # ื”ื›ื™ ืงืฉื™ื— price_ok_strict & gen_ok, # ื‘ืœื™ ื’ื™ืœ price_ok_wide & gen_ok, # ื”ืจื—ื‘ืช ื˜ื•ื•ื— ืชืงืฆื™ื‘ age_ok & gen_ok, # ื‘ืœื™ ืชืงืฆื™ื‘ gen_ok, # ืจืง ืžื’ื“ืจ np.ones(len(CATALOG), bool), # ื”ื›ืœ ] idx = np.array([], dtype=int) for m in mask_chain: cand = np.where(m)[0] if cand.size: idx = cand break # ---------- Query & base similarities ---------- q = profile_to_query(p) qv = EMB.query_vec(q).astype("float32") embs = np.asarray(EMB.embs, "float32") emb_sims = embs[idx] @ qv # ---------- Bonuses (ืขื“ื™ื™ืŸ ืžื—ื•ืฉื‘ื™ื ืขืœ ื”ืžื•ืขืžื“ื™ื ืฉื ื‘ื—ืจื•) ---------- target = (lo + hi) / 2.0 if hi > lo else hi prices = CATALOG.iloc[idx]["price_usd"].to_numpy() price_bonus = np.clip(.12 - np.abs(prices - target) / max(target, 1.0), 0, .12).astype("float32") int_bonus = _interest_bonus(p, idx) occ_bonus = _occasion_bonus(idx, p.get("occ_ui", "Birthday")) # Pre-score ืขื ื”ื’ื ื•ืช ืœ-NaN/Inf pre = np.nan_to_num(emb_sims + price_bonus + int_bonus + occ_bonus, nan=0.0, posinf=0.0, neginf=0.0) # ---------- Local candidate pool ---------- K1 = max(1, min(48, idx.size)) try: top_local = np.argpartition(-pre, K1 - 1)[:K1] except Exception: top_local = np.argsort(-pre)[:K1] cand_idx = idx[top_local] # ---------- Feature normalization ---------- emb_n = _minmax(np.nan_to_num(emb_sims[top_local], nan=0.0)) price_n = _minmax(np.nan_to_num(price_bonus[top_local],nan=0.0)) int_n = _minmax(np.nan_to_num(int_bonus[top_local], nan=0.0)) occ_n = _minmax(np.nan_to_num(occ_bonus[top_local], nan=0.0)) # ---------- Optional cross-encoder ---------- ce = _load_cross_encoder() if ce is not None: docs = CATALOG.loc[cand_idx, "doc"].tolist() pairs = [(q, d) for d in docs] k_ce = min(24, len(pairs)) tl = np.argpartition(-emb_n, k_ce - 1)[:k_ce] ce_raw = np.array(ce.predict([pairs[i] for i in tl]), "float32") ce_n = np.zeros_like(emb_n) ce_n[tl] = _minmax(ce_raw) else: ce_n = np.zeros_like(emb_n) # ---------- Final score ---------- final = np.nan_to_num(.56*emb_n + .26*ce_n + .10*int_n + .05*occ_n + .03*price_n, nan=0.0) # ---------- Select top-3 with diversity ---------- k = int(min(3, cand_idx.size)) pick = _mmr_select(cand_idx, final, k=k) if k > 0 else np.array([], dtype=int) if pick.size == 0: pick = cand_idx[np.argsort(-final)[:min(3, cand_idx.size)]] # ---------- Build result ---------- res = CATALOG.loc[pick].copy() pos = {int(cand_idx[i]): i for i in range(len(cand_idx))} res["similarity"] = [float(final[pos[int(i)]]) if int(i) in pos else np.nan for i in pick] # ---------- Optional synthetic #4 ---------- if include_synth: try: synth = pick_best_synthetic(p, qv, generate_synthetic_candidates(p, n=int(max(1, synth_n)))) if synth is not None: res = pd.concat( [res, pd.DataFrame([synth])[["name","short_desc","price_usd","image_url","similarity"]]], ignore_index=True ) except Exception: pass # ืœื ืฉื•ื‘ืจื™ื ืืช ื”-UI ืื ื”-DIY ื ื›ืฉืœ return res[["name","short_desc","price_usd","image_url","similarity"]].reset_index(drop=True) q=profile_to_query(p); qv=EMB.query_vec(q).astype("float32") emb_sims=np.asarray(EMB.embs,"float32")[idx]@qv target=(lo+hi)/2.0 if hi>lo else hi; prices=CATALOG.iloc[idx]["price_usd"].to_numpy() # Small bonus for being close to the budget mid-point price_bonus=np.clip(.12-np.abs(prices-target)/max(target,1.0),0,.12).astype("float32") int_bonus=_interest_bonus(p,idx); occ_bonus=_occasion_bonus(idx,p.get("occ_ui","Birthday")) pre=emb_sims+price_bonus+int_bonus+occ_bonus # Keep a local candidate pool for cost/quality tradeoff K1=min(48,idx.size); top_local=np.argpartition(-pre,K1-1)[:K1]; cand_idx=idx[top_local] emb_n=_minmax(emb_sims[top_local]); price_n=_minmax(price_bonus[top_local]); int_n=_minmax(int_bonus[top_local]); occ_n=_minmax(occ_bonus[top_local]) ce=_load_cross_encoder(); if ce is not None: # Optional cross-encoder re-ranking on a smaller slice docs=CATALOG.loc[cand_idx,"doc"].tolist(); pairs=[(q,d) for d in docs] k_ce=min(24,len(pairs)); tl=np.argpartition(-emb_n,k_ce-1)[:k_ce]; ce_raw=np.array(ce.predict([pairs[i] for i in tl]),"float32"); ce_n=np.zeros_like(emb_n); ce_n[tl]=_minmax(ce_raw) else: ce_n=np.zeros_like(emb_n) # Final weighted score (tuned manually) final=(.56*emb_n+.26*ce_n+.10*int_n+.05*occ_n+.03*price_n).astype("float32") pick=_mmr_select(cand_idx,final,k=min(3,cand_idx.size)) res=CATALOG.loc[pick].copy(); pos={int(cand_idx[i]):i for i in range(len(cand_idx))}; res["similarity"]=[float(final[pos[int(i)]]) for i in pick] # === NEW: synthetic #4 === synth = pick_best_synthetic(p, qv, generate_synthetic_candidates(p, n=10)) if synth is not None: res = pd.concat( [res, pd.DataFrame([synth])[["name","short_desc","price_usd","image_url","similarity"]]], ignore_index=True ) return res[["name","short_desc","price_usd","image_url","similarity"]].reset_index(drop=True) # ===== DIY (FLAN-only) ===== DIY_MODEL_ID=os.getenv("DIY_MODEL_ID","google/flan-t5-small"); DIY_DEVICE=torch.device("cpu") MAX_INPUT_TOKENS=int(os.getenv("MAX_INPUT_TOKENS","384")); DIY_MAX_NEW_TOKENS=int(os.getenv("DIY_MAX_NEW_TOKENS","120")) # Light aliases to seed the DIY gift title with an interest token INTEREST_ALIASES={"Reading":["book","novel","literary"],"Fashion":["style","chic","silk"],"Home decor":["candle","wall","jar"],"Technology":["tech","gadget","usb"],"Movies":["film","cinema","poster"]} FALLBACK_NOUNS=["Kit","Set","Bundle","Box","Pack"] _diy_cache_model={} def _load_flan(mid:str): # Lazy-load and cache FLAN-T5 on CPU if mid in _diy_cache_model: return _diy_cache_model[mid] tok=AutoTokenizer.from_pretrained(mid, use_fast=True, trust_remote_code=True) mdl=AutoModelForSeq2SeqLM.from_pretrained(mid, trust_remote_code=True, use_safetensors=True).to(DIY_DEVICE).eval() _diy_cache_model[mid]=(tok,mdl); return _diy_cache_model[mid] @torch.inference_mode() def _gen(tok, mdl, prompt, max_new_tokens=64, do_sample=False, temperature=.9, top_p=.95, seed=None): # Small wrapper for deterministic/non-deterministic generation if seed is None: seed=random.randint(1,10_000_000) random.seed(seed); torch.manual_seed(seed) enc=tok(prompt, truncation=True, max_length=MAX_INPUT_TOKENS, return_tensors="pt"); enc={k:v.to(DIY_DEVICE) for k,v in enc.items()} out=mdl.generate(**enc, max_new_tokens=max_new_tokens, eos_token_id=tok.eos_token_id, pad_token_id=tok.eos_token_id, **({"do_sample":True,"temperature":temperature,"top_p":top_p} if do_sample else {"do_sample":False,"num_beams":1})) return tok.decode(out[0], skip_special_tokens=True).strip() def _choose_interest_token(interests): # Pick a representative token to inject into the DIY name for it in interests: if INTEREST_ALIASES.get(it): return random.choice(INTEREST_ALIASES[it]) return (interests[0].split()[0].lower() if interests else "gift") def _title_case(s): s=re.sub(r'\s+',' ',s).strip(); s=re.sub(r'["โ€œโ€โ€˜โ€™]+','',s); return " ".join([w.capitalize() for w in s.split()]) def _sanitize_name(name, interests): # Clean LLM-proposed name and enforce a short, interest-infused title for b in [r"^the name\b",r"\bmember of the family\b",r"^name\b",r"^title\b"]: name=re.sub(b,"",name,flags=re.I).strip() name=re.sub(r'[:\-โ€“โ€”]+$',"",name).strip(); alias=_choose_interest_token(interests) if alias not in name.lower(): tokens=[t for t in re.split(r"[\s\-]+",name) if t] name=(f"{alias.capitalize()} "+(" ".join([t.capitalize() for t in tokens]) if tokens else random.choice(FALLBACK_NOUNS))) if len(tokens)<4 else " ".join([tokens[0],alias.capitalize(),*tokens[1:]]) name=re.sub(r'\b(Home Decor:?\s*){2,}','Home Decor ',name,flags=re.I); name=_title_case(name)[:80] if len(name.split())<3: name=f"{alias.capitalize()} {random.choice(FALLBACK_NOUNS)}" return name def _split_list_text(s,seps): # Parse list-like text returned by LLM into clean items (fallback across separators) s=s.strip() for sep in seps: if sep in s: parts=[p.strip(" -โ€ข*.,;:") for p in s.split(sep) if p.strip(" -โ€ข*.,;:")] if len(parts)>=2: return parts return [p.strip(" -โ€ข*.,;:") for p in re.split(r"[\n\r;]+", s) if p.strip(" -โ€ข*.,;:")] def _coerce_materials(items): # Normalize materials list: dedupe, keep short, ensure quantities, pad with basics out=[] for it in items: it=re.sub(r'\s+',' ',it).strip(" -โ€ข*.,;:"); if not it: continue it=re.sub(r'(\b\w+\b)(?:\s+\1){2,}',r'\1',it,flags=re.I) if len(it)>60: it=it[:58]+"โ€ฆ" if not re.search(r"\d",it): it+=" x1" if it.lower() not in [x.lower() for x in out]: out.append(it) if len(out)>=8: break base=["Small gift box x1","Decorative paper x2","Twine 2 m","Cardstock sheets x2","Double-sided tape x1","Stickers x8","Ribbon 1 m","Fine-tip marker x1"] for b in base: if len(out)>=6: break if b.lower() not in [x.lower() for x in out]: out.append(b) return out[:8] def _coerce_steps(items): # Normalize step list: trim, remove numbering, enforce sentence case, pad to 6+ out=[] for it in items: it=it.strip(" -โ€ข*.,;:"); if not it: continue it=re.sub(r'\s+',' ',it); if len(it)>120: it=it[:118]+"โ€ฆ" it=re.sub(r'^(?:\d+[\).\s-]*)','',it); it=it[0].upper()+it[1:] if it else it; out.append(it) if len(out)>=8: break while len(out)<6: out.append(f"Refine and decorate step {len(out)+1}") return out[:8] def _only_int(s): m=re.search(r"-?\d+",s); return int(m.group()) if m else None def _clamp_num(v,lo,hi,default): # Clamp numeric values into a valid range; fallback to default or midpoint try: x=float(v); return int(min(max(x,lo),hi)) except: return int((lo+hi)/2 if default is None else default) def diy_generate(profile:Dict)->Tuple[dict,str]: # Generate a DIY gift object (name, overview, materials, steps, cost, time) tok,mdl=_load_flan(DIY_MODEL_ID) p={"recipient_name":profile.get("recipient_name","Recipient"),"relationship":profile.get("relationship","Friend"), "occ_ui":profile.get("occ_ui","Birthday"),"occasion":profile.get("occ_ui","Birthday"),"interests":profile.get("interests",[]), "budget_min":int(float(profile.get("budget_min",10))),"budget_max":int(float(profile.get("budget_max",100))), "age_range":profile.get("age_range","any"),"gender":profile.get("gender","any")} lang="English"; ints_str=", ".join(p["interests"]) or "general" prompt_name=(f"Return ONLY a DIY gift NAME in Title Case (4โ€“8 words). Must include at least one interest token from: " f"{', '.join(sum(([it]+INTEREST_ALIASES.get(it,[]) for it in p['interests']), [])) or 'gift'}. " f"Occasion: {p['occ_ui']}. Relationship: {p['relationship']}. Language: {lang}. Forbidden: the words 'name','title','family'. " "No quotes, no trailing punctuation.\nExamples:\nReading โ†’ Literary Candle Bookmark Kit\nTechnology โ†’ Gadget Cable Organizer Set\nHome decor โ†’ Rustic Jar Candle Bundle\nOutput:") name=_sanitize_name(_gen(tok,mdl,prompt_name, max_new_tokens=24, do_sample=False), p["interests"]) overview=_gen(tok,mdl,(f"Write EXACTLY 2 sentences in {lang} for a handmade gift called '{name}'. Mention {p['recipient_name']} " f"({p['relationship']}) and the occasion ({p['occ_ui']}). Explain how it reflects the interests: {ints_str}. " "No lists, no emojis. Output only the two sentences."), max_new_tokens=80, do_sample=True, temperature=.9, top_p=.95) materials=_split_list_text(_gen(tok,mdl,(f"List 6 concise materials with quantities to make '{name}' cheaply. Keep total within " f"{p['budget_min']}-{p['budget_max']} USD. Output ONLY a comma-separated list."), max_new_tokens=96, do_sample=False), [",",";"]) steps=_split_list_text(_gen(tok,mdl,(f"Write 6 short imperative steps to make '{name}'. Output ONLY a semicolon-separated list."), max_new_tokens=120, do_sample=True, temperature=.9, top_p=.95), [";","\n"]) cost=_only_int(_gen(tok,mdl,(f"Return ONE integer total cost in USD between {p['budget_min']}-{p['budget_max']}. Output NUMBER only."), max_new_tokens=6, do_sample=False)) minutes=_only_int(_gen(tok,mdl,"Return ONE integer minutes between 20 and 180. Output NUMBER only.", max_new_tokens=6, do_sample=False)) idea={"gift_name":name,"overview":overview,"materials_needed":_coerce_materials(materials),"steps":_coerce_steps(steps), "estimated_cost_usd":_clamp_num(cost,p["budget_min"],p["budget_max"],None),"estimated_time_minutes":_clamp_num(minutes,20,180,60)} return idea,"ok" def generate_synthetic_candidates(profile, n=10): # Use FLAN-based DIY generator to create N lightweight candidates (name/overview/price) cands = [] lo, hi = int(float(profile.get("budget_min", 10))), int(float(profile.get("budget_max", 100))) for _ in range(n): idea, _ = diy_generate(profile) # Already returns name/overview/estimated_cost price = int(idea.get("estimated_cost_usd") or random.randint(lo, hi)) name = idea.get("gift_name", "Custom DIY Gift")[:160] desc = (idea.get("overview", "") or "").strip()[:300] doc = f"{name} | custom | {desc}".lower() cands.append({"name": name, "short_desc": desc, "price_usd": price, "image_url": "", "doc": doc}) return cands def pick_best_synthetic(profile, qv, candidates): # Embed synthetic candidates and pick the one most similar to the query vector if not candidates: return None docs = [c["doc"] for c in candidates] vecs = EMB.model.encode(docs, convert_to_numpy=True, normalize_embeddings=True) sims = vecs @ qv j = int(np.argmax(sims)) best = candidates[j].copy() best["similarity"] = float(sims[j]) return best # --------------------- Personalized Message (FLAN + validation) --------------------- # Implementation ported from the Colab; tone-specific constraints + simple checks. MSG_MODEL_ID = "google/flan-t5-small" MSG_DEVICE = "cpu" TEMP_RANGE = (0.88, 1.10) TOPP_RANGE = (0.90, 0.96) REP_PENALTY = 1.12 MSG_MAX_NEW_TOKENS = 90 MSG_MAX_TRIES = 4 _last_msg: Optional[str] = None _msg_tok, _msg_mdl = None, None TONE_STYLES: Dict[str, Dict[str, List[str]]] = { "Formal": { "system": "Write 2โ€“3 refined sentences with professional courtesy and clarity.", "rules": [ "You may begin with 'Dear {name},' but keep it concise.", "Use precise vocabulary; avoid colloquialisms.", "Conclude with a dignified line." ], }, "Casual": { "system": "Write 2โ€“3 relaxed sentences with natural, friendly language.", "rules": [ "Keep it light and conversational.", "Reference one concrete interest detail.", "End upbeat without clichรฉs." ], }, "Funny": { "system": "Write 2โ€“3 witty sentences with playful humor.", "rules": [ "Add one subtle pun linked to the occasion or interests.", "No slapstick; keep it tasteful.", "End with a cheeky nudge." ], }, "Heartfelt": { "system": "Write 2โ€“3 warm, sincere sentences with genuine sentiment.", "rules": [ "Open with an image or specific detail; avoid templates.", "Let one verb carry the energy; minimal adjectives.", "Close with a crisp, personal wish." ], }, "Inspirational": { "system": "Write 2โ€“3 uplifting sentences with forward-looking energy.", "rules": [ "Honor a trait or effort implied by the interests.", "Use a subtle metaphor; avoid grandiose platitudes.", "Finish with a compact, future-facing line." ], }, "Playful": { "system": "Write 2โ€“3 lively sentences with bounce and rhythm.", "rules": [ "Sneak a gentle internal rhyme or alliteration.", "Keep syntax varied and musical.", "Land on a spirited close." ], }, "Romantic": { "system": "Write 2โ€“3 intimate sentences, warm and elegant.", "rules": [ "Reference a shared moment or interest; keep it subtle.", "No clichรฉs or over-sweet phrasing.", "End with a soft, affectionate note." ], }, "Appreciative": { "system": "Write 2โ€“3 sentences that express genuine appreciation.", "rules": [ "Name a specific quality or habit tied to the interests.", "Avoid business thank-you clichรฉs.", "Close with concise gratitude." ], }, "Encouraging": { "system": "Write 2โ€“3 supportive sentences that motivate gently.", "rules": [ "Acknowledge progress or perseverance (hinted by interests).", "Offer one practical, hopeful sentiment.", "Finish with a compact encouragement." ], }, } BAN_PHRASES = [ ] OPENERS = [ "Hereโ€™s to a moment that fits you perfectly:", "A note made just for you:", "Because you make celebrations easy to love:", "For a day that sounds like you:", ] CLOSERS = [ "Enjoy every bitโ€”youโ€™ve earned it.", "Keep doing the things that light you up.", "Hereโ€™s to more of what makes you, you.", "Let this be a spark for the year ahead.", ] def _msg_load(): # Lazy-load FLAN for message generation (CPU) global _msg_tok, _msg_mdl if _msg_tok is None or _msg_mdl is None: _msg_tok = AutoTokenizer.from_pretrained(MSG_MODEL_ID) _msg_mdl = AutoModelForSeq2SeqLM.from_pretrained(MSG_MODEL_ID) _msg_mdl.to(MSG_DEVICE).eval() return _msg_tok, _msg_mdl def _norm(s: str) -> str: # Collapse whitespace for more reliable validators return re.sub(r"\s+", " ", s or "").strip() def _sentences_n(s: str) -> int: # Count sentences via punctuation boundaries return len([p for p in re.split(r"(?<=[.!?])\s+", s.strip()) if p]) def _contains_any(text: str, terms: List[str]) -> bool: # Case-insensitive containment check for any of the given terms t = text.lower() return any(term for term in terms if term) and any((term or "").lower() in t for term in terms) def _too_similar(a: str, b: str, n=3, thr=0.85) -> bool: # Approximate de-duplication via n-gram Jaccard similarity def ngrams(txt): toks = re.findall(r"[a-zA-Z']+", txt.lower()) return set(tuple(toks[i:i+n]) for i in range(max(0, len(toks)-n+1))) A, B = ngrams(a), ngrams(b) if not A or not B: return False j = len(A & B) / max(1, len(A | B)) return j >= thr def _clean_occasion(occ: str) -> str: # Normalize typographic apostrophes to ASCII and trim return (occ or "").replace("โ€™","'").strip() def _build_prompt(profile: Dict[str, Any]) -> Tuple[str, Dict[str,str]]: # Compose a guided prompt (tone + micro-rules) for the message LLM name = profile.get("recipient_name", "Friend") rel = profile.get("relationship", "Friend") occ = _clean_occasion(profile.get("occ_ui") or profile.get("occasion") or "Birthday") tone = profile.get("tone", "Heartfelt") ints = ", ".join(profile.get("interests", [])) or "general interests" style = TONE_STYLES.get(tone, TONE_STYLES["Heartfelt"]) opener = random.choice(OPENERS) closer = random.choice(CLOSERS) spice = random.choice([ "Use one concrete visual detail.", "Shift the rhythm slightly in the second sentence.", "Let one verb carry most of the energy; keep adjectives minimal.", "Add a gentle internal rhyme." ]) lines = [ "Generate a short gift-card message in English (2โ€“3 sentences).", f"Recipient: {name} ({rel}). Occasion: {occ}. Interests: {ints}. Tone: {tone}.", style["system"], "Rules:", *[f"- {r}" for r in style["rules"]], "- No emojis. No bullet points.", f"- Start with: \"{opener}\" (continue naturally, not as a header).", f"- End with a natural line similar to: \"{closer}\" (rephrase; do not quote).", f"- {spice}", "Output only the message; no extra commentary.", ] return "\n".join(lines), dict(name=name, occ=occ) @torch.inference_mode() def generate_personal_message(profile: Dict[str, Any], seed: Optional[int]=None, previous_message: Optional[str]=None) -> Dict[str, Any]: # Sample multiple generations with slight sampling variance, validate, and return best global _last_msg tok, mdl = _msg_load() if seed is None: seed = random.randint(1, 10_000_000) tried = [] for attempt in range(1, MSG_MAX_TRIES+1): random.seed(seed); torch.manual_seed(seed) prompt, need = _build_prompt(profile) temp = random.uniform(*TEMP_RANGE) topp = random.uniform(*TOPP_RANGE) enc = tok(prompt, truncation=True, max_length=512, return_tensors="pt").to(MSG_DEVICE) out_ids = mdl.generate( **enc, do_sample=True, temperature=temp, top_p=topp, max_new_tokens=MSG_MAX_NEW_TOKENS, repetition_penalty=REP_PENALTY, pad_token_id=tok.eos_token_id, eos_token_id=tok.eos_token_id, ) text = _norm(tok.decode(out_ids[0], skip_special_tokens=True)) # ===== Validators (mirrors the Colab logic) ===== ok_len = 1 <= _sentences_n(text) <= 3 name_ok = _contains_any(text, [need["name"].lower()]) occ_ok = _contains_any(text, [need["occ"].lower(), need["occ"].split()[0].lower()]) ban_ok = not _contains_any(text, BAN_PHRASES) prev = previous_message or _last_msg dup_ok = (prev is None) or (not _too_similar(text, prev, n=3, thr=0.85)) if all([ok_len, name_ok, occ_ok, ban_ok, dup_ok]): _last_msg = text return {"message": text, "meta": {"tone": profile.get("tone","Heartfelt"), "temperature": round(temp,2), "top_p": round(topp,2), "seed": seed, "attempt": attempt, "model": MSG_MODEL_ID}} tried.append({"text": text}); seed += 17 # Fallback if all attempts failed validation fallback = tried[-1]["text"] if tried else f"Happy {(_clean_occasion(profile.get('occ_ui') or 'day')).lower()}, {profile.get('recipient_name','Friend')}!" _last_msg = fallback return {"message": fallback, "meta": {"failed": True, "model": MSG_MODEL_ID, "tone": profile.get("tone","Heartfelt")}} # --------------------- END Personalized Message --------------------- # ===== Rendering & UI ===== def first_sentence(s,max_chars=140): # Extract the first sentence or truncate; keeps the HTML cards compact s=(s or "").strip(); if not s: return "" cut=s.split(". ")[0]; return cut if len(cut)<=max_chars else cut[:max_chars-1]+"โ€ฆ" def render_top3_html(df, age_label): # Render the 3 catalog picks plus the optional 4th "Generated" item if df is None or df.empty: return "No results found within the current filters." rows=[] for i, r in df.iterrows(): name=str(r.get("name","")).replace("|","\\|").replace("*","\\*").replace("_","\\_") desc=str(first_sentence(r.get("short_desc",""))).replace("|","\\|").replace("*","\\*").replace("_","\\_") price=r.get("price_usd"); sim=r.get("similarity"); img=r.get("image_url","") or "" price_str=f"${price:.0f}" if pd.notna(price) else "N/A"; sim_str=f"{sim:.3f}" if pd.notna(sim) else "โ€”" img_html=f'' if img else "" tag = "Generated" if i==3 else f"#{i+1}" rows.append(f"""
{name} ({tag})
{desc}
Price: {price_str} ยท Age: {age_label} ยท Score: {sim_str}
{img_html}
""") return "\n".join(rows) with gr.Blocks(title="๐ŸŽ GIfty โ€” Recommender + DIY", css=""" #explain{opacity:.85;font-size:.92em;margin-bottom:8px;} .gr-dataframe thead{display:none;} .gr-dataframe table{border-collapse:separate!important;border-spacing:0 10px!important;table-layout:fixed;width:100%;} .gr-dataframe tbody tr{cursor:pointer;display:block;background:linear-gradient(180deg,#fff,#fafafa);border-radius:14px;border:1px solid #e9eef5;box-shadow:0 1px 1px rgba(16,24,40,.04),0 1px 2px rgba(16,24,40,.06);padding:10px 12px;transition:transform .06s ease, box-shadow .12s ease, background .12s ease;} .gr-dataframe tbody tr:hover{transform:translateY(-1px);background:#f8fafc;box-shadow:0 3px 10px rgba(16,24,40,.08);} .gr-dataframe tbody tr td{border:0!important;padding:4px 8px!important;vertical-align:middle;font-size:.92rem;line-height:1.3;} .gr-dataframe tbody tr td:nth-child(1){font-weight:700;font-size:1rem;letter-spacing:.2px;} .gr-dataframe tbody tr td:nth-child(2),.gr-dataframe tbody tr td:nth-child(4){opacity:.8;} .gr-dataframe tbody tr td:nth-child(3),.gr-dataframe tbody tr td:nth-child(9),.gr-dataframe tbody tr td:nth-child(6),.gr-dataframe tbody tr td:nth-child(5){display:inline-block;background:#eff4ff;color:#243b6b;border:1px solid #dbe5ff;border-radius:999px;padding:2px 10px!important;font-size:.84rem;margin:2px 6px 2px 0;} .gr-dataframe tbody tr td:nth-child(7),.gr-dataframe tbody tr td:nth-child(8){display:inline-block;background:#f1f5f9;border:1px solid #e2e8f0;color:#0f172a;border-radius:10px;padding:2px 8px!important;font-variant-numeric:tabular-nums;margin:2px 6px 2px 0;} .handsontable .wtBorder,.handsontable .htBorders,.handsontable .wtBorder.current{display:none!important;} .gr-dataframe table td:focus{outline:none!important;box-shadow:none!important;} """) as demo: gr.Markdown(TITLE) gr.Markdown("### Quick examples (click a row to auto-fill)", elem_id="explain") EXAMPLES=[(["Technology","Movies"],"Birthday",25,45,"Daniel","Friend","adult (18โ€“64)","male","Funny"), (["Art","Reading","Home decor"],"Anniversary",30,60,"Rotem","Romantic partner","adult (18โ€“64)","female","Romantic"), (["Gaming","Photography"],"Birthday",30,120,"Omer","Family - Sibling","teen (13โ€“17)","male","Playful"), (["Reading","Art"],"Graduation",15,35,"Maya","Friend","adult (18โ€“64)","female","Heartfelt"), (["Science","Crafts"],"Holidays",15,30,"Adam","Family - Child","kid (3โ€“12)","male","Encouraging")] EX_COLS=["Recipient","Relationship","Interests","Occasion","Age group","Gender","Min $","Max $","Tone"] EX_DF=pd.DataFrame([[name,rel," + ".join(interests),occ,age,gender,bmin,bmax,tone] for (interests,occ,bmin,bmax,name,rel,age,gender,tone) in EXAMPLES], columns=EX_COLS) ex_df=gr.Dataframe(value=EX_DF, interactive=False, wrap=True); gr.Markdown("---") with gr.Row(): recipient_name=gr.Textbox(label="Recipient name", value="Daniel") relationship=gr.Dropdown(label="Relationship", choices=RECIPIENT_RELATIONSHIPS, value="Friend") with gr.Row(): occasion=gr.Dropdown(label="Occasion", choices=OCCASION_UI, value="Birthday") age=gr.Dropdown(label="Age group", choices=list(AGE_OPTIONS.keys()), value="adult (18โ€“64)") gender=gr.Dropdown(label="Recipient gender", choices=GENDER_OPTIONS, value="male") interests=gr.CheckboxGroup(label="Interests (select a few)", choices=INTEREST_OPTIONS, value=["Technology","Movies"], interactive=True) with gr.Row(): budget_min=gr.Slider(label="Min budget (USD)", minimum=5, maximum=500, step=1, value=25) budget_max=gr.Slider(label="Max budget (USD)", minimum=5, maximum=500, step=1, value=45) tone=gr.Dropdown(label="Message tone", choices=MESSAGE_TONES, value="Funny") go=gr.Button("Get GIfty!") gr.Markdown("### ๐Ÿ“Œ Input summary"); out_summary = gr.HTML(visible=False) gr.Markdown("### ๐ŸŽฏ Recommendations"); out_top3=gr.HTML() gr.Markdown("### ๐Ÿ› ๏ธ DIY Gift"); out_diy_md=gr.Markdown() gr.Markdown("### ๐Ÿ’Œ Personalized Message"); out_msg=gr.Markdown() run_token=gr.State(0) def _on_example_select(evt: gr.SelectData): # Clicking a row fills the input widgets with that example r=int(evt.index[0] if isinstance(evt.index,(list,tuple)) else evt.index); row=EX_DF.iloc[r]; ints=[s.strip() for s in str(row["Interests"]).split("+")] return (ints,row["Occasion"],int(row["Min $"]),int(row["Max $"]),row["Recipient"],row["Relationship"],row["Age group"],row["Gender"],row["Tone"]) ex_df.select(_on_example_select, outputs=[interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone]) def render_diy_md(j:dict)->str: # Nicely format the DIY object as markdown if not j: return "_DIY generation failed._" steps=j.get('step_by_step_instructions', j.get('steps', [])) parts = [ f"**{j.get('gift_name','(no name)')}**","", j.get('overview','').strip(),"", "**Materials**","\n".join(f"- {m}" for m in j.get('materials_needed',[])),"", "**Steps**","\n".join(f"{i+1}. {s}" for i,s in enumerate(steps)),"", f"**Estimated cost:** ${j.get('estimated_cost_usd','?')} ยท **Time:** {j.get('estimated_time_minutes','?')} min" ] return "\n".join(parts) def input_summary_html(p, age_label): # Render a compact summary of the current input above the results ints = ", ".join(p.get("interests", [])) or "โ€”" budget = f"${int(float(p.get('budget_min',0)))}โ€“${int(float(p.get('budget_max',0)))}" name = p.get("recipient_name","Friend"); rel = p.get("relationship","Friend") occ = p.get("occ_ui", "Birthday"); gender = (p.get("gender","any") or "any").capitalize() return f"""
Recipient: {name} ({rel})
Occasion: {occ}
Age: {age_label}
Gender: {gender}
Budget: {budget}
Interests: {ints}
""" def _build_profile(ints, occ, bmin, bmax, name, rel, age_label, gender_val, tone_val): # Convert UI widget values into an internal profile dict try: bmin=float(bmin); bmax=float(bmax) except: bmin,bmax=5.0,500.0 if bmin>bmax: bmin,bmax=bmax,bmin return {"recipient_name":name or "Friend","relationship":rel or "Friend","interests":ints or [],"occ_ui":occ or "Birthday","budget_min":bmin,"budget_max":bmax,"age_range":AGE_OPTIONS.get(age_label,"any"),"gender":(gender_val or "any").lower(),"tone":tone_val or "Heartfelt"} def start_run(curr): # Simple monotonic counter to tie together chained events return int(curr or 0) + 1 def predict_summary_only(rt, *args): # args mapping: # 0: interests, 1: occasion, 2: budget_min, 3: budget_max, # 4: recipient_name, 5: relationship, 6: age_label, 7: gender, 8: tone p = _build_profile(*args) return gr.update(value=input_summary_html(p, args[6]), visible=True), rt def predict_recs_only(rt, *args): p = _build_profile(*args) top3 = recommend_top3_budget_first(p, include_synth=False) # ืžื”ื™ืจ return gr.update(value=render_top3_html(top3, args[6]), visible=True), rt def predict_recs_with_synth(rt, *args): p = _build_profile(*args) synth_n = int(os.getenv("SYNTH_N", "2")) df = recommend_top3_budget_first(p, include_synth=True, synth_n=synth_n) return gr.update(value=render_top3_html(df, args[6]), visible=True), rt def predict_diy_only(rt, *args): p = _build_profile(*args) diy_json, _ = diy_generate(p) return gr.update(value=render_diy_md(diy_json), visible=True), rt def predict_msg_only(rt, *args): p = _build_profile(*args) msg_obj = generate_personal_message(p) return gr.update(value=msg_obj["message"], visible=True), rt ev_start = go.click(start_run, inputs=[run_token], outputs=[run_token], queue=True) # 1) ืกื™ื›ื•ื ืงืœื˜ (ืžื™ื™ื“ื™) ev_start.then( predict_summary_only, inputs=[run_token, interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone], outputs=[out_summary, run_token], queue=True, ) # 2) ื”ืžืœืฆื•ืช ืžื”ื™ืจื•ืช (Top-3 ืœืœื ืกื™ื ืชื˜ื™) recs_fast = ev_start.then( predict_recs_only, inputs=[run_token, interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone], outputs=[out_top3, run_token], queue=True, ) # 3) ื—ื™ืฉื•ื‘ ืกื™ื ืชื˜ื™ ื›ืฉืœื‘ ื”ืžืฉืš โ€” ืžืจืขื ืŸ ืืช ืื•ืชื• out_top3 ื›ืฉืžื•ื›ืŸ recs_fast.then( predict_recs_with_synth, inputs=[run_token, interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone], outputs=[out_top3, run_token], queue=True, ) # 4) DIY ื•ึพMessage ื™ื›ื•ืœื™ื ืœืจื•ืฅ ื‘ืžืงื‘ื™ืœ ืœึพ(3) ev_start.then( predict_diy_only, inputs=[run_token, interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone], outputs=[out_diy_md, run_token], queue=True, ) ev_start.then( predict_msg_only, inputs=[run_token, interests, occasion, budget_min, budget_max, recipient_name, relationship, age, gender, tone], outputs=[out_msg, run_token], queue=True, ) if __name__=="__main__": demo.launch()