""" TripAlchemy - Gradio Application (Part 5 of the assignment) ================================================================ Flow: pick a city -> set vibe sliders (+ optional filters / free text) -> get the top-5 matched real experiences (embeddings + slider hybrid, via scripts/recsys.py) -> generate one AI-written itinerary narrative weaving together the top-3 of them (via scripts/narrative_generator.py). Data sourcing (per the assignment's constraints): - In production (the deployed HF Space) this reads the dataset straight from the HF Dataset repo and the winning embeddings from the HF Model repo - see HF_DATASET_REPO / HF_EMBEDDINGS_REPO below. Set those and flip USE_HF_HUB = True before deploying. - For local development it falls back to the local files in ../data/, which is what runs by default right now. Generation backend: - Uses narrative_generator.hf_inference_api_generate_fn, which calls a small instruction-tuned HF model through the HF Inference API. Needs an HF_TOKEN (set as an HF Space "Secret" in production, or exported locally). Falls back to the mock generator with a visible warning if no token is present, so the UI still works end-to-end for local testing. """ import os import sys import json import html from pathlib import Path import gradio as gr _esc = html.escape # Local repo layout keeps recsys/narrative_generator in ../scripts; the deployed # HF Space uploads them next to app.py, so fall back to this file's own directory. SCRIPT_DIR = Path(__file__).parent.parent / "scripts" if not (SCRIPT_DIR / "recsys.py").exists(): SCRIPT_DIR = Path(__file__).parent sys.path.insert(0, str(SCRIPT_DIR)) from recsys import RecommendationEngine, CATEGORIES # noqa: E402 from narrative_generator import ( # noqa: E402 generate_itinerary, hf_inference_api_generate_fn, local_causal_generate_fn, mock_generate_fn, ) # ===================================================== # Config - flip these before deploying to HF Space # ===================================================== # Configured via environment variables / HF Space "Variables & secrets" - no code # editing needed to deploy. Set USE_HF_HUB=true + the two repo ids as Space Variables, # and HF_TOKEN as a Space Secret, and the app reads dataset + embeddings from the Hub. USE_HF_HUB = os.environ.get("USE_HF_HUB", "false").lower() == "true" HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO", "your-username/tripalchemy-experiences") HF_EMBEDDINGS_REPO = os.environ.get("HF_EMBEDDINGS_REPO", "your-username/tripalchemy-embeddings") GENERATION_MODEL_ID = os.environ.get("GENERATION_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct") DATA_DIR = Path(__file__).parent.parent / "data" CITIES_FILE = DATA_DIR / "cities.json" EXPERIENCES_FILE = DATA_DIR / "experiences.json" EMBEDDINGS_FILE = DATA_DIR / "embeddings" / "winning_model.npz" CATEGORY_LABELS = { "culinary": "๐Ÿฝ๏ธ Culinary", "historical": "๐Ÿ›๏ธ Historical", "shopping": "๐Ÿ›๏ธ Shopping", "nature": "๐ŸŒฒ Nature", "nightlife": "๐ŸŒƒ Nightlife", "art_culture": "๐ŸŽจ Art & Culture", } COUNTRY_FLAGS = { "Argentina": "๐Ÿ‡ฆ๐Ÿ‡ท", "Australia": "๐Ÿ‡ฆ๐Ÿ‡บ", "Bulgaria": "๐Ÿ‡ง๐Ÿ‡ฌ", "Czech Republic": "๐Ÿ‡จ๐Ÿ‡ฟ", "France": "๐Ÿ‡ซ๐Ÿ‡ท", "Germany": "๐Ÿ‡ฉ๐Ÿ‡ช", "Greece": "๐Ÿ‡ฌ๐Ÿ‡ท", "Hungary": "๐Ÿ‡ญ๐Ÿ‡บ", "Iceland": "๐Ÿ‡ฎ๐Ÿ‡ธ", "Indonesia": "๐Ÿ‡ฎ๐Ÿ‡ฉ", "Italy": "๐Ÿ‡ฎ๐Ÿ‡น", "Japan": "๐Ÿ‡ฏ๐Ÿ‡ต", "Mexico": "๐Ÿ‡ฒ๐Ÿ‡ฝ", "Morocco": "๐Ÿ‡ฒ๐Ÿ‡ฆ", "Netherlands": "๐Ÿ‡ณ๐Ÿ‡ฑ", "New Zealand": "๐Ÿ‡ณ๐Ÿ‡ฟ", "Portugal": "๐Ÿ‡ต๐Ÿ‡น", "Singapore": "๐Ÿ‡ธ๐Ÿ‡ฌ", "South Africa": "๐Ÿ‡ฟ๐Ÿ‡ฆ", "South Korea": "๐Ÿ‡ฐ๐Ÿ‡ท", "Spain": "๐Ÿ‡ช๐Ÿ‡ธ", "Thailand": "๐Ÿ‡น๐Ÿ‡ญ", "Turkey": "๐Ÿ‡น๐Ÿ‡ท", "UAE": "๐Ÿ‡ฆ๐Ÿ‡ช", "USA": "๐Ÿ‡บ๐Ÿ‡ธ", "United Kingdom": "๐Ÿ‡ฌ๐Ÿ‡ง", "Vietnam": "๐Ÿ‡ป๐Ÿ‡ณ", } def _flag(country: str) -> str: return COUNTRY_FLAGS.get(country, "๐ŸŒ") QUICK_STARTERS = { "๐ŸŒน Romantic Lisbon Weekend": { "city": "lisbon", "sliders": {"culinary": 0.8, "historical": 0.3, "shopping": 0.1, "nature": 0.2, "nightlife": 0.5, "art_culture": 0.5}, "budget": 150, "energy": "low", }, "๐Ÿœ Solo Tokyo Foodie Crawl": { "city": "tokyo", "sliders": {"culinary": 0.95, "historical": 0.1, "shopping": 0.3, "nature": 0.0, "nightlife": 0.6, "art_culture": 0.2}, "budget": 80, "energy": "moderate", }, "๐Ÿ›๏ธ Budget Rome History Dive": { "city": "rome", "sliders": {"culinary": 0.3, "historical": 0.95, "shopping": 0.0, "nature": 0.1, "nightlife": 0.0, "art_culture": 0.6}, "budget": 40, "energy": "moderate", }, } def load_cities(): if USE_HF_HUB: from huggingface_hub import hf_hub_download path = hf_hub_download(repo_id=HF_DATASET_REPO, filename="cities.json", repo_type="dataset") else: path = CITIES_FILE with open(path, encoding="utf-8") as f: data = json.load(f) return {c["id"]: f"{_flag(c['country'])} {c['name']}, {c['country']}" for c in data["cities"]} def build_embed_fn(report_path=None): """Loads the SAME embedding model that produced winning_model.npz, so free-text / slider-sentence queries land in the same vector space as the precomputed document embeddings. Falls back to slider-only search (no text embedding) if sentence-transformers or the network isn't available, e.g. when testing inside a restricted sandbox.""" report_path = Path(report_path) if report_path else DATA_DIR / "embeddings" / "comparison_report.json" hf_id = "sentence-transformers/all-MiniLM-L6-v2" # sane default query_prefix = "" # some models (E5) require a "query: " prefix at query time if report_path.exists(): report = json.loads(report_path.read_text(encoding="utf-8")) winner = report.get("winner") if winner and winner in report.get("models", {}): hf_id = report["models"][winner]["hf_id"] query_prefix = report["models"][winner].get("prefix_query", "") try: from sentence_transformers import SentenceTransformer model = SentenceTransformer(hf_id) def _embed(texts): # apply the winner's query prefix so queries match the precomputed # document embeddings (which used the model's doc prefix). texts = [query_prefix + t for t in texts] return model.encode(texts, normalize_embeddings=True, show_progress_bar=False) print(f"โœ… embed_fn ready ({hf_id}, query_prefix={query_prefix!r})") return _embed except Exception as e: print(f"โš ๏ธ Could not load {hf_id} ({e}). Falling back to slider-only search " f"(no free-text embedding search this session).") return None def get_engine(): if USE_HF_HUB: # Production path: pull straight from the HF Hub (dataset repo for experiences, # model repo for the winning embeddings + the comparison report that tells the # app which embedding model + query prefix to use). from huggingface_hub import hf_hub_download exp_path = hf_hub_download(repo_id=HF_DATASET_REPO, filename="experiences.json", repo_type="dataset") emb_path = hf_hub_download(repo_id=HF_EMBEDDINGS_REPO, filename="winning_model.npz", repo_type="model") report_path = hf_hub_download(repo_id=HF_EMBEDDINGS_REPO, filename="comparison_report.json", repo_type="model") else: exp_path, emb_path = str(EXPERIENCES_FILE), str(EMBEDDINGS_FILE) report_path = str(DATA_DIR / "embeddings" / "comparison_report.json") return RecommendationEngine.load(exp_path, emb_path, embed_fn=build_embed_fn(report_path)) def get_generate_fn(): """Real HF-model generation with a reliable fallback chain: 1. HF Inference API (fast, needs a token + the model to be served by a provider) 2. a local HF model loaded in the Space (transformers, CPU) - always works, no token Neither is the banned random/formula generation; the mock is a last resort only.""" token = os.environ.get("HF_TOKEN") api_fn = None if token: try: api_fn = hf_inference_api_generate_fn(model_id=GENERATION_MODEL_ID, token=token) except Exception as e: print(f"โš ๏ธ HF Inference API init failed: {e}") _local = {} def _generate(prompt: str) -> str: if api_fn is not None: try: return api_fn(prompt) except Exception as e: print(f"โš ๏ธ HF Inference API call failed ({type(e).__name__}); using local model fallback.") if "fn" not in _local: print("Loading local generation model (Qwen2.5-0.5B-Instruct)...") _local["fn"] = local_causal_generate_fn("Qwen/Qwen2.5-0.5B-Instruct") return _local["fn"](prompt) return _generate CITIES = load_cities() ENGINE = get_engine() GENERATE_FN = get_generate_fn() def format_card(exp: dict) -> str: scores = exp["category_scores"] top_cats = [(c, v) for c, v in sorted(scores.items(), key=lambda kv: -kv[1])[:3] if v > 0] chips = "".join(f"{CATEGORY_LABELS[c]} {v:.1f}" for c, v in top_cats) tags = "".join(f"{_esc(t)}" for t in exp.get("vibe_tags", [])[:4]) nb = exp.get("neighborhood") loc = f"{_esc(nb)}, {_esc(exp['city_name'])}" if nb else _esc(exp["city_name"]) return ( f"
" f"
{_esc(exp.get('title', 'Untitled'))}
" f"
{_flag(exp.get('country',''))} {loc}" f"  โ€ข  ๐Ÿ’ฐ ${exp.get('cost_usd','?')}  โ€ข  โฑ๏ธ {exp.get('duration_hours','?')}h" f"  โ€ข  {_esc(str(exp.get('time_of_day', 'flexible')))}
" f"
{_esc(exp.get('description',''))}
" f"
{chips}{tags}
" f"
" ) def run_search(city_id, culinary, historical, shopping, nature, nightlife, art_culture, free_text, max_budget, energy): if not city_id: return "โš ๏ธ Pick a city first.", "", "" weights = { "culinary": culinary, "historical": historical, "shopping": shopping, "nature": nature, "nightlife": nightlife, "art_culture": art_culture, } energy_filter = None if energy == "any" else energy can_use_text = ENGINE.embed_fn is not None results = ENGINE.recommend( category_weights=weights, free_text=free_text or None, city_id=city_id, max_budget_usd=max_budget if max_budget else None, energy=energy_filter, alpha=(0.5 if free_text else 0.2) if can_use_text else 0.0, use_text_similarity=can_use_text, top_k=5, ) if not results: return "No experiences matched those filters โ€” try loosening the budget or sliders.", "", "" cards_md = "".join(format_card(e) for e in results) # The narrative is secondary: never let a generation failure block the experiences. def _template(exps): p = [f"{_esc(e['title'])} in {_esc(e.get('neighborhood') or e['city_name'])}" for e in exps] s = "Begin your day with " + p[0] if len(p) > 1: s += ", then head to " + p[1] if len(p) > 2: s += ", and round it off at " + p[2] return s + " โ€” a day woven from the vibe you set." try: gen = generate_itinerary(results[:3], GENERATE_FN, user_context=free_text or "") nar = (gen.get("narrative") or "").strip() low = nar.lower() # guard against a model that echoes the prompt or returns junk echoed = ("travel writer" in low or "real experiences pulled" in low or "curated database" in low or len(nar) < 25) narrative_body = _template(results[:3]) if echoed else _esc(nar) except Exception: narrative_body = _template(results[:3]) narrative_md = (f"
โœจ Your AI-woven itinerary
" f"
{narrative_body}
") grounded_md = "
๐Ÿ”— Grounded in: " + ", ".join(_esc(e["title"]) for e in results[:3]) + "
" return cards_md, narrative_md, grounded_md def apply_quick_starter(name): cfg = QUICK_STARTERS[name] s = cfg["sliders"] return ( cfg["city"], s["culinary"], s["historical"], s["shopping"], s["nature"], s["nightlife"], s["art_culture"], "", cfg["budget"], cfg["energy"], ) CUSTOM_CSS = """ /* Force a consistent LIGHT look regardless of the viewer's dark-mode preference, by overriding Gradio's theme CSS variables (works in both :root and .dark). */ :root, .dark, gradio-app, .gradio-container { --body-background-fill:#f4f2ef !important; --background-fill-primary:#ffffff !important; --background-fill-secondary:#efedea !important; --block-background-fill:#ffffff !important; --block-label-background-fill:#ffffff !important; --block-title-text-color:#2b3040 !important; --block-label-text-color:#3a3f4b !important; --body-text-color:#2b3040 !important; --body-text-color-subdued:#5a6072 !important; --border-color-primary:#e4e2de !important; --input-background-fill:#ffffff !important; --input-border-color:#e0ded9 !important; --panel-background-fill:#ffffff !important; --neutral-950:#ffffff !important; --neutral-900:#ffffff !important; --neutral-800:#f2f0ed !important; --neutral-700:#e4e2de !important; } body, gradio-app, .gradio-container {background:#f4f2ef !important; color:#2b3040 !important;} .gradio-container {max-width: 1180px !important; margin: auto !important;} #hero {background: linear-gradient(120deg,#ff6a3d 0%,#f7455d 46%,#8b3dff 100%); border-radius: 18px; padding: 30px 34px; margin-bottom: 10px; color:#fff; box-shadow: 0 12px 32px rgba(180,60,110,.30);} #hero h1 {margin:0; font-size:2.35rem; font-weight:800; letter-spacing:-.6px; color:#fff;} #hero p {margin:8px 0 0; font-size:1.07rem; opacity:.97;} #hero .pill {display:inline-block; background:rgba(255,255,255,.18); border:1px solid rgba(255,255,255,.4); border-radius:999px; padding:4px 13px; font-size:.8rem; margin:12px 8px 0 0;} .exp-card {background:#ffffff !important; border:1px solid #ececf1; border-left:5px solid #ff6a3d; border-radius:14px; padding:16px 18px; margin-bottom:14px; box-shadow:0 3px 14px rgba(30,30,60,.10);} .exp-card, .exp-card * {color:#2b3040 !important;} /* beat gradio dark-mode white text */ .exp-title {font-size:1.18rem !important; font-weight:800 !important; color:#d63e1f !important; margin-bottom:5px;} .exp-meta {font-size:.87rem !important; color:#586074 !important; margin-bottom:9px;} .exp-desc {font-size:.95rem !important; color:#2b3040 !important; line-height:1.55; margin-bottom:11px;} .chip-row {line-height:2.2;} .cat-chip {display:inline-block; background:#ffe9df !important; color:#c9451f !important; border-radius:999px; padding:3px 11px; font-size:.79rem !important; font-weight:700 !important; margin-right:6px; white-space:nowrap;} .vibe-tag {display:inline-block; background:#e4f2f1 !important; color:#1c6d69 !important; border-radius:999px; padding:3px 10px; font-size:.76rem !important; font-weight:600 !important; margin-right:6px; white-space:nowrap;} .narr-box {background:linear-gradient(135deg,#fff6ef,#fdeade) !important; border:1px solid #ffd0b5; border-radius:14px; padding:18px 22px; margin-top:8px;} .narr-box, .narr-box * {color:#2b3040 !important;} .narr-title {font-weight:800 !important; color:#d63e1f !important; font-size:1.14rem !important; margin-bottom:8px;} .narr-body {font-size:1rem !important; line-height:1.6;} .grounded {font-size:.8rem !important; color:#7a7f8a !important; margin-top:12px;} .panel-title {font-weight:800 !important; font-size:1.12rem !important; color:#d63e1f !important; margin:4px 0 8px;} """ with gr.Blocks(title="TripAlchemy") as demo: gr.HTML( f"" "
" "

๐Ÿงช TripAlchemy

" "

Set your travel vibe โ€” get real matching experiences + one AI-woven itinerary.

" "๐ŸŒ 30 cities" "โœจ 10,000+ experiences" "๐Ÿง  embedding + slider hybrid search" "
" ) with gr.Row(): with gr.Column(scale=1): city_dd = gr.Dropdown(choices=[(v, k) for k, v in CITIES.items()], label="๐Ÿ™๏ธ Pick a city", value="lisbon") gr.Markdown("**Set your vibe** โ€” how strongly do you want each?") culinary_s = gr.Slider(0, 1, 0.5, step=0.05, label=CATEGORY_LABELS["culinary"]) historical_s = gr.Slider(0, 1, 0.5, step=0.05, label=CATEGORY_LABELS["historical"]) shopping_s = gr.Slider(0, 1, 0.2, step=0.05, label=CATEGORY_LABELS["shopping"]) nature_s = gr.Slider(0, 1, 0.2, step=0.05, label=CATEGORY_LABELS["nature"]) nightlife_s = gr.Slider(0, 1, 0.3, step=0.05, label=CATEGORY_LABELS["nightlife"]) art_s = gr.Slider(0, 1, 0.3, step=0.05, label=CATEGORY_LABELS["art_culture"]) free_text_tb = gr.Textbox(label="๐Ÿ’ฌ Describe the vibe (optional)", placeholder="e.g. cozy jazz bar with cocktails") budget_s = gr.Slider(0, 500, 200, step=10, label="๐Ÿ’ฐ Max budget per experience (USD)") energy_dd = gr.Dropdown(choices=["any", "low", "moderate", "high"], value="any", label="โšก Energy level") search_btn = gr.Button("๐Ÿ”ฎ Find my experiences", variant="primary", size="lg") gr.Markdown("**โšก Or try a quick starter:**") with gr.Row(): starter_buttons = [gr.Button(name, size="sm") for name in QUICK_STARTERS] with gr.Column(scale=2): gr.HTML("
๐ŸŽฏ Your matched experiences
") results_md = gr.HTML() narrative_md = gr.HTML() grounded_md = gr.HTML() inputs = [city_dd, culinary_s, historical_s, shopping_s, nature_s, nightlife_s, art_s, free_text_tb, budget_s, energy_dd] search_btn.click(fn=run_search, inputs=inputs, outputs=[results_md, narrative_md, grounded_md]) for name, btn in zip(QUICK_STARTERS, starter_buttons): btn.click(fn=(lambda n=name: apply_quick_starter(n)), outputs=inputs).then( fn=run_search, inputs=inputs, outputs=[results_md, narrative_md, grounded_md] ) if __name__ == "__main__": demo.launch()