""" 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"
Set your travel vibe โ get real matching experiences + one AI-woven itinerary.
" "๐ 30 cities" "โจ 10,000+ experiences" "๐ง embedding + slider hybrid search" "