"""Game Advisor — Gradio app (HuggingFace Spaces entry point). Flow: the user searches the catalog for games they've played and (optionally) sets year / platform / language filters and a ranking mode; we build a state, rank the catalog, and show the top recommendations with cover art + details. "Mark as played → refine" folds a suggestion back into the history and re-runs. Run locally: python app.py (serves on http://localhost:7860) On HF Spaces this file is the entry point; ship data/policy.pt, data/game_embeddings_matrix.npy, data/game_embeddings_index.pkl, and data/embedding_scalers.pkl alongside it (IGDB creds go in the Space secrets). """ import sys from collections import Counter from pathlib import Path # Make the `recommender` package (source/recommender) importable whether run from repo root or elsewhere. sys.path.insert(0, str(Path(__file__).resolve().parent / "source")) import gradio as gr from dotenv import load_dotenv from PIL import Image, ImageDraw from thefuzz import fuzz from recommender import artifacts from recommender.inference import recommend from recommender.state_builder import cold_start_state load_dotenv() # --- Catalog-derived UI choices ----------------------------------------------- _idx = artifacts.index_frame() ALL_NAMES = sorted(_idx["name"].tolist()) PLATFORMS = [p for p, _ in Counter(p for lst in _idx["platforms"] for p in lst).most_common(20)] ANY_LANG = "(any)" LANGUAGES = [ANY_LANG] + [l for l, _ in Counter(l for lst in _idx["language_supports"] for l in lst).most_common()] YEAR_MIN, YEAR_MAX = 1975, 2026 # Play-history dropdown search is HYBRID: choices are preloaded once at startup # (instant, zero server dependency) AND a key_up handler swaps them for # full-catalog search results as the user types. Preloading the FULL ~26k # catalog is not an option — Gradio renders every filtered option in the DOM # (no virtualization, verified against 6.15's compiled Dropdown JS), which is # what made v1 laggy — so the preload is the N games with the best source # coverage (how many of the five collection APIs knew the game), tie-broken by # user/critic score, a strong fame proxy already in games.csv. That cutoff hid # whole famous franchises (Fable, FIFA) whose rows are real but sparse, hence # the server-search layer: it makes every cataloged title findable. Server # key_up proved flaky on HF Spaces twice before (events silently dropped; the # first time was SSR, fixed by ssr_mode=False, the second unexplained) — the # hybrid is deliberately drop-safe: a lost keystroke event just leaves the # client-filtered preload list, which is the complete pre-hybrid behavior. # Last resort stays too: type a full title + Enter (allow_custom_value; # state_builder fuzzy-resolves it at recommend time). N_PRELOAD = 3000 def _preload_names() -> list[str]: meta = artifacts.games_metadata() df = meta[meta.index.isin(set(_idx["name"]))] coverage = ( (df["cover_url"].fillna("[]") != "[]").astype(int) + df["description"].notna().astype(int) + (df["metacritic_rating"] > 0).astype(int) + (df["user_rating"] > 0).astype(int) + (df["rawg_rating"] > 0).astype(int) + (df["igdb_rating"] > 0).astype(int) + (df["hltb_rating"] > 0).astype(int) + (df["main_story"] > 0).astype(int) ) # Blend, not lexicographic: ratings (max 8 pts, matching coverage's max 8) # can outvote one missing metadata source. Pure coverage-first ordering cut # inside the coverage-7 band and excluded every coverage-6 game — e.g. # Elden Ring (cov 6, user .84, critic .96) lost to "Pure Chess" (cov 7, # user .71). Blended, acclaimed games clear the bar regardless of one # sparse column. score = coverage + 4.0 * (df["user_rating"] + df["metacritic_rating"]) top = sorted(zip(df.index, score.values), key=lambda r: r[1], reverse=True)[:N_PRELOAD] names = [r[0] for r in top] # Pins guarantee dropdown presence regardless of the blended score: # - preload_pins.json famous games matched by scripts/audit_catalog.py # (some rows — review-bombed scores, thin metadata — can't clear any top-N cutoff) # - preload_pins_updater.json games appended by scripts/update_games.py # (fresh releases whose early metadata is too thin to rank) # Missing/invalid pin files degrade to top-N only. import json as _json known = set(_idx["name"]) have = set(names) for pins_file in ("preload_pins.json", "preload_pins_updater.json"): try: pins = _json.loads((artifacts.DATA_DIR / pins_file).read_text()) except (FileNotFoundError, ValueError): continue extra = [p for p in pins if p in known and p not in have] names += extra have.update(extra) return names PRELOAD_NAMES = _preload_names() # Ranking modes: UI label -> (inference mode, one-line explanation shown with results). MODES = { "Profile + RL (recommended)": ( "profile_rl", "Kept the games closest to your history, then reranked them with the learned policy.", ), "RL only (trust the policy)": ( "rl_only", "Ranked all matching games with the learned policy alone (exposes the policy's raw behavior).", ), "Profile only (no RL)": ( "profile_only", "Ranked purely by similarity to your play history — content-based, the policy is not used.", ), } MODE_LABELS = list(MODES) # Theme: keep section titles in the default purple, but make interactive # *selection* (slider fill, checkbox/radio selected) a brighter purple so it # stands out from the titles instead of blending in. SELECT_COLOR = "#7c3aed" # violet-600, a darker selection accent vs the purple titles SELECT_TEXT = "#ffffff" # readable text on the darker chips THEME = gr.themes.Soft(primary_hue="purple").set( # Section/block titles stay at primary_500 (purple). These are the actual # variables that color the *selection* surfaces (verified against Gradio's # CSS): slider fill, selected dropdown options, selected radio/checkboxes. slider_color=SELECT_COLOR, slider_color_dark=SELECT_COLOR, checkbox_label_background_fill_selected=SELECT_COLOR, checkbox_label_background_fill_selected_dark=SELECT_COLOR, checkbox_background_color_selected=SELECT_COLOR, checkbox_background_color_selected_dark=SELECT_COLOR, ) # The multiselect chips in the input box use --checkbox-label-background-fill # (a neutral, not a theme "selected" variable), so recolor them via CSS. Also # add breathing room above the result-sizing sliders and style the detail panel. CUSTOM_CSS = f""" .gradio-container .token {{ background: {SELECT_COLOR} !important; color: {SELECT_TEXT} !important; }} #result-sizing {{ margin-top: 22px; }} #result-sizing .wrap {{ gap: 28px; }} #detail-panel {{ border: 1px solid var(--border-color-primary); border-radius: var(--radius-lg); padding: 16px 18px; background: var(--background-fill-secondary); }} """ # Gradio's Dropdown doesn't scroll the highlighted option into view during # keyboard navigation, so arrowing moves the highlight through off-screen items. # This on-load hook scrolls the active option into view on arrow keys # (block:'nearest' = minimal scroll, no jarring recenter). # Scroll-follow for dropdown keyboard navigation. # # Verified against Gradio's compiled Dropdown JS (Dropdown-B9BMXBuu.js): # - the highlighted
  • gets class "active" (all options are rendered; no # virtualization), and # - the component's only native scrollTo runs when the list *opens* (to show # the selected item) — nothing keeps the highlight visible while arrowing, # so it walks off-screen. This shim fills exactly that gap. # # The app may mount inside 's shadow root, where document-level # querySelector / MutationObserver silently see nothing (why earlier attempts # did nothing). So we search both document and the shadow root, and attach via # a cheap poll instead of a DOM-added observer — immune to portal/shadow # surprises; near-zero work when no dropdown is open. Injected via (not # launch(js=...)) so execution doesn't depend on Gradio's js hook. Scrolling # uses block:'nearest' — a no-op when the item is already visible, so mouse # hover never jiggles the list. KEYNAV_HEAD = """ """ # Placeholder cover for games whose metadata has no image. _PLACEHOLDER = Image.new("RGB", (264, 374), (38, 38, 46)) ImageDraw.Draw(_PLACEHOLDER).text((132, 187), "no cover art", fill=(150, 150, 160), anchor="mm") def _dedup(seq): return list(dict.fromkeys(x for x in seq if x)) def _pct(x): return f"{x * 100:.0f}%" if x and x > 0 else None def _header_md(mode_label, resolved, filters, n_recs): why = MODES[mode_label][1] head = f"**Mode:** {mode_label} — {why}\n\n" f"**History:** {', '.join(resolved) if resolved else '_none (cold start)_'}\n\n" f"**Filters:** year {filters['year_min']}–{filters['year_max']}" f"{' · ' + ', '.join(filters['platforms']) if filters.get('platforms') else ''}" f"{' · ' + filters['language'] if filters.get('language') else ''}" if not n_recs: return head + "\n\n_No games match those filters. Widen the year range or platforms._" return head + f"\n\n**{n_recs} recommendations** — click a cover to see its details." def _card_title(r, rank=None): """Title + headline scores (shown above the play-toggle button).""" yr = r["release_year"] or (r["release"][:4] if r.get("release") else "year unknown") meta_bits = [f"`{r['score']:.0%} match`", str(yr)] crit = _pct(r["metacritic_rating"]) usr = _pct(r["user_rating"]) if crit: meta_bits.append(f"critics {crit}") if usr: meta_bits.append(f"users {usr}") title = f"### {rank}. {r['name']}" if rank else f"### {r['name']}" return title + "\n\n" + " · ".join(meta_bits) def _card_body(r): """The detailed fields (shown below the play-toggle button).""" parts = [] if r["genres"]: parts.append("**Genres:** " + ", ".join(_dedup(r["genres"])[:8])) if r["platforms"]: parts.append("**Platforms:** " + ", ".join(_dedup(r["platforms"])[:8])) devpub = [] if r["developers"]: devpub.append("**Developer:** " + ", ".join(_dedup(r["developers"])[:3])) if r["publishers"]: devpub.append("**Publisher:** " + ", ".join(_dedup(r["publishers"])[:3])) if devpub: parts.append(" · ".join(devpub)) ttb = [] if r["main_story"] > 0: ttb.append(f"main {r['main_story']:.0f}h") if r["main_extra"] > 0: ttb.append(f"+extras {r['main_extra']:.0f}h") if r["completionist"] > 0: ttb.append(f"100% {r['completionist']:.0f}h") if ttb: parts.append("**Time to beat:** " + " · ".join(ttb)) if r["language_supports"]: langs = _dedup(r["language_supports"]) extra = f" _(+{len(langs) - 8} more)_" if len(langs) > 8 else "" parts.append("**Languages:** " + ", ".join(langs[:8]) + extra) if r["keywords"]: parts.append("**Tags:** " + ", ".join(_dedup(r["keywords"])[:10])) desc = (r["description"] or "").strip() parts.append(desc or "_(no description available)_") return "\n\n".join(parts) _CLICK_PROMPT = "_Select a cover from the grid to see the game's details here._" _ADD_LABEL = "+ I played this — add to history" _REMOVE_LABEL = "✓ In your history — click to remove" def _cover_html(r): """Enlarged cover shown at the top of the detail panel (controlled markup, so selecting a cover never triggers the gallery preview's page-scroll).""" url = r.get("cover_url_display") if url: return f'cover' return '
    no cover art
    ' # Top fuzzy matches fed into the dropdown while typing. Kept small on purpose: # the popup shows a handful of rows and navigation is nicer over a short, # high-precision list — typing one more letter narrows better than arrowing # through dozens. SEARCH_LIMIT = 20 # Keys that navigate/commit within the open dropdown rather than change the # query text. key_up fires on ALL keys, and re-emitting `choices` on these # re-renders the list and snaps the highlight back to the top — so pressing the # arrows to pick a match felt like the list kept resetting. We skip re-filtering # for these and leave the dropdown untouched (gr.skip()). _NAV_KEYS = { "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter", "Escape", "Tab", "Home", "End", "PageUp", "PageDown", "Shift", "Control", "Alt", "Meta", "CapsLock", } def search_games(selected, evt: gr.KeyUpData): """Full-catalog autocomplete layered over the preloaded dropdown (see the N_PRELOAD comment for the hybrid rationale). Reads the typed text from the key_up event, builds a cheap candidate pool over ALL ~26k titles (substring, falling back to all-tokens-present), then ranks it with thefuzz `WRatio` so the closest titles surface first ("witcher" -> "The Witcher 3: Wild Hunt"). Already-selected games are kept in the choices so their chips stay valid. Clearing the text restores the preload list (the old design returned a near-empty list there, which read as the catalog vanishing).""" # Don't re-filter (and reset the highlight) on arrow/enter/etc. navigation. if evt.key in _NAV_KEYS: return gr.skip() selected = list(selected or []) q = (evt.input_value or "").strip().lower() if len(q) < 2: return gr.update(choices=list(dict.fromkeys(selected + PRELOAD_NAMES))) pool = [n for n in ALL_NAMES if q in n.lower()] if len(pool) < 5: # no/few substring hits -> try all-tokens-present toks = q.split() pool = list(dict.fromkeys(pool + [n for n in ALL_NAMES if all(t in n.lower() for t in toks)])) pool = pool[:3000] # bound the fuzzy pass for pathological short queries matches = sorted(pool, key=lambda n: fuzz.WRatio(q, n.lower()), reverse=True)[:SEARCH_LIMIT] return gr.update(choices=list(dict.fromkeys(selected + matches))) def _generate(played_inputs, year_min, year_max, platforms, language, mode_label, top_n, candidate_k, enrich_live): if year_max < year_min: year_min, year_max = year_max, year_min state, resolved = cold_start_state(played_inputs) filters = {"year_min": int(year_min), "year_max": int(year_max)} if platforms: filters["platforms"] = platforms if language and language != ANY_LANG: filters["language"] = language mode = MODES[mode_label][0] recs = recommend( state, filters, played_games=resolved, top_n=int(top_n), mode=mode, candidate_k=int(candidate_k), enrich=bool(enrich_live), ) gallery = [(r["cover_url_display"] or _PLACEHOLDER, f"{r['name']} ({r['release_year'] or '?'})") for r in recs] header = _header_md(mode_label, resolved, filters, len(recs)) return gallery, recs, header def _panel_for(r, rank, played): """The 5 detail-panel outputs (cover, title, toggle, body, selected name).""" label = _REMOVE_LABEL if r["name"] in (played or []) else _ADD_LABEL return (_cover_html(r), _card_title(r, rank=rank), gr.update(value=label, visible=True), _card_body(r), r["name"]) def on_recommend(played, year_min, year_max, platforms, language, mode_label, top_n, candidate_k, enrich_live): played = list(played or []) gallery, recs, header = _generate(played, year_min, year_max, platforms, language, mode_label, top_n, candidate_k, enrich_live) # Auto-select recommendation #1: the panel (and its play-toggle) is always # populated right after recommending, and the gallery's selected_index is # explicitly reset — otherwise the component keeps the previous run's # selection and clicking the same tile position never fires `select`. if recs: panel = _panel_for(recs[0], 1, played) return gr.update(value=gallery, selected_index=0), recs, header, *panel return gr.update(value=gallery, selected_index=None), recs, header, "", "", gr.update(visible=False), _CLICK_PROMPT, None def on_select(recs, played, evt: gr.SelectData): """Show the clicked cover (enlarged in the panel) + its info, with the toggle reflecting whether the game is already in history.""" # Clicking the already-selected tile fires a deselect (selected=False): # keep the panel as-is instead of blanking it (which read as "the button # disappeared after the first click"). if not getattr(evt, "selected", True): return gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip() if recs and 0 <= evt.index < len(recs): return _panel_for(recs[evt.index], evt.index + 1, played) return "", "", gr.update(visible=False), _CLICK_PROMPT, None def toggle_played(played, name, current_label): """Add or remove the shown game from history, flip the toggle label, and keep the main button labelled 'Refine' while there's any history.""" played = list(played or []) if not name: return gr.update(), gr.update(), gr.update() if name in played: played.remove(name) label = _ADD_LABEL else: played.append(name) label = _REMOVE_LABEL main = "Refine" if played else "Recommend" # Update ONLY the value — choices belong to the search layer (preload at # rest, search_games results while typing); overwriting them here blanked # the search list after the first toggle. Games not in the choices (e.g. a # recommended long-tail title) stay valid as chips via allow_custom_value. return gr.update(value=played), gr.update(value=label), gr.update(value=main) with gr.Blocks(title="Game Advisor") as demo: gr.Markdown("# 🎮 Game Advisor\n" "Offline-RL game recommender. Type games you've enjoyed into the search box and hit " "**Recommend**. Click a cover to enlarge it and see the game's details; the toggle next to " "each title adds/removes it from your history so you can refine.") # Hybrid search (see the N_PRELOAD comment): ships with the top-N preload # filtered natively in the browser, while key_up swaps in full-catalog # search results — so a dropped typing event degrades to the preload list # instead of a blank one. allow_custom_value=True keeps the last resort: # type the full title and press Enter — state_builder.cold_start_state # fuzzy-resolves it against the whole catalog at recommend time (and drops # what it can't match). It also makes the choices-swap race harmless: a # selection landing after a swap can't fail Dropdown.preprocess validation. played_dropdown = gr.Dropdown( PRELOAD_NAMES, multiselect=True, filterable=True, allow_custom_value=True, label="Games you've played", info="Type to search the full catalog, then pick the games you've played.", ) with gr.Accordion("Filters & ranking options", open=False): with gr.Row(): year_min = gr.Slider(YEAR_MIN, YEAR_MAX, value=YEAR_MIN, step=1, label="Released from") year_max = gr.Slider(YEAR_MIN, YEAR_MAX, value=YEAR_MAX, step=1, label="Released to") platforms = gr.CheckboxGroup(PLATFORMS, label="Platforms (any of)") with gr.Row(): language = gr.Dropdown(LANGUAGES, value=ANY_LANG, label="Language") mode_label = gr.Radio(MODE_LABELS, value=MODE_LABELS[0], label="Ranking mode") with gr.Group(elem_id="result-sizing"): gr.Markdown("#### Result sizing") top_n = gr.Slider(3, 12, value=10, step=1, label="Number of recommendations") candidate_k = gr.Slider( 10, 200, value=30, step=10, label="History anchor size (Profile + RL only)", info="How many history-closest games the policy reranks. Larger = looser anchoring.", ) enrich_live = gr.Checkbox( value=True, label="Fetch cover art + descriptions live (RAWG + IGDB; slower, fills gaps in the local data)", ) recommend_btn = gr.Button("Recommend", variant="primary") recs_state = gr.State([]) # full records of the current recommendations selected_name = gr.State(None) # canonical name of the cover currently shown header_md = gr.Markdown() with gr.Row(): with gr.Column(scale=3): # allow_preview=False: clicking a cover only fires `select` (fills the # panel on the right). The built-in preview is off because it scrolls # the page to center the enlarged image; we show the enlarged cover in # the detail panel instead, so the grid stays put and nothing scrolls. gallery = gr.Gallery(label="Recommendations", columns=3, height=460, object_fit="contain", allow_preview=False) with gr.Column(scale=2, elem_id="detail-panel"): cover_html = gr.HTML("") detail_title = gr.Markdown("") played_toggle = gr.Button(_ADD_LABEL, visible=False, size="sm") detail_body = gr.Markdown(_CLICK_PROMPT) rec_inputs = [year_min, year_max, platforms, language, mode_label, top_n, candidate_k, enrich_live] recommend_btn.click( on_recommend, [played_dropdown, *rec_inputs], [gallery, recs_state, header_md, cover_html, detail_title, played_toggle, detail_body, selected_name], ) gallery.select(on_select, [recs_state, played_dropdown], [cover_html, detail_title, played_toggle, detail_body, selected_name]) played_toggle.click( toggle_played, [played_dropdown, selected_name, played_toggle], [played_dropdown, played_toggle, recommend_btn], ) # Full-catalog autocomplete as the user types (hybrid search layer). # trigger_mode="always_last": collapse a burst of keystrokes into one # search, so stale responses from earlier keys can't re-render the list # (and reset the highlight) after the user has started arrow-navigating. played_dropdown.key_up(search_games, [played_dropdown], [played_dropdown], show_progress="hidden", trigger_mode="always_last") if __name__ == "__main__": # ssr_mode=False: HF Spaces enables Gradio's Node-SSR by default # (GRADIO_SSR_MODE=true). Under SSR the served page never delivered the # dropdown's key_up events (typing showed no suggestions), while the same # event posted straight to /gradio_api/queue/join returned choices fine — # i.e. backend healthy, SSR frontend broken. SSR only speeds first paint, # so pin it off; the explicit param overrides the Space's env default. demo.launch(server_name="0.0.0.0", server_port=7860, theme=THEME, css=CUSTOM_CSS, head=KEYNAV_HEAD, ssr_mode=False)