import gradio as gr
import pandas as pd
import numpy as np
import plotly.graph_objects as go
import html
import traceback
# Import custom modules
from data_loader import load_movie_data, get_trending_movies, get_kpi_statistics
from styles import CUSTOM_CSS, NAVBAR_HTML, FOOTER_HTML
from utils import filter_and_sort_movies, paginate_dataframe, fuzzy_search_movies
from components import (
build_movie_grid_html,
build_movie_details_html,
build_recommendations_grid_html,
build_similar_grid_html,
get_rating_distribution_chart,
get_year_distribution_chart,
get_predictions_score_chart,
get_movie_relationship_graph,
get_single_movie_rating_distribution,
get_single_movie_popularity_timeline,
get_diagnostics_html
)
from recommender import RecommenderManager
from poster_service import poster_service
# Initialize data and model manager
df_raw = load_movie_data("movie_metadata.parquet")
recommender = RecommenderManager()
print("Loading recommender model (this may take 30-60 seconds)...", flush=True)
recommender.load_resources()
print("Recommender ready.", flush=True)
# Pre-calculate catalog statistics
kpis = get_kpi_statistics(df_raw)
trending_df = get_trending_movies(df_raw, limit=20)
# Determine year range boundaries
valid_years = df_raw[df_raw["year"] > 0]["year"]
min_year = int(valid_years.min()) if not valid_years.empty else 1900
max_year = int(valid_years.max()) if not valid_years.empty else 2006
# Number of cards per page
PAGE_SIZE = 20
def get_page_label(current, total):
return f"Page {current + 1} of {total}"
# Callback handlers for Movie Explorer Catalog
def filter_catalog(query, year_min, year_max, min_rating, min_rating_count, sort_by):
"""
Filters the movie explorer database and returns page 0 results.
"""
year_range = [year_min, year_max]
filtered, search_info = filter_and_sort_movies(df_raw, query, year_range, min_rating, min_rating_count, sort_by)
# Build status banner based on match type
msg = ""
if search_info and search_info["match_type"] and query.strip():
mt = search_info["match_type"]
q = html.escape(query.strip())
count = len(filtered)
if mt == "EXACT_MATCH":
msg = (
f'
'
f'Results for "{q}"'
f'
'
)
elif mt == "FRANCHISE_MATCH":
msg = (
f''
f'Found {count} related titles for "{q}"'
f'
'
)
elif mt == "PARTIAL_MATCH":
msg = (
f''
f'"{q}" '
f'is not available. Showing related titles instead.'
f'
'
)
elif mt == "NO_MATCH":
msg = (
f''
f'No matches found for "{q}"'
f'
'
)
# Paginate page 0
sliced, total_pages = paginate_dataframe(filtered, 0, PAGE_SIZE)
grid_html = msg + build_movie_grid_html(sliced)
label = get_page_label(0, total_pages)
# Disable/enable pagination buttons based on total pages
prev_interactive = False
next_interactive = total_pages > 1
return filtered, 0, grid_html, label, gr.update(interactive=prev_interactive), gr.update(interactive=next_interactive)
def change_page(direction, current_page, filtered_df):
"""
Paginates the filtered dataframe forward or backward.
"""
total_records = len(filtered_df)
total_pages = max(1, int(np.ceil(total_records / PAGE_SIZE)))
new_page = current_page + direction
new_page = max(0, min(new_page, total_pages - 1))
sliced, _ = paginate_dataframe(filtered_df, new_page, PAGE_SIZE)
grid_html = build_movie_grid_html(sliced)
label = get_page_label(new_page, total_pages)
prev_interactive = new_page > 0
next_interactive = new_page < (total_pages - 1)
return new_page, grid_html, label, gr.update(interactive=prev_interactive), gr.update(interactive=next_interactive)
def select_movie(movie_id):
"""
Queries details for a selected movie ID and renders the details panel along with Plotly charts.
"""
if not movie_id or str(movie_id).strip() == "":
return build_movie_details_html(None), go.Figure(), go.Figure()
try:
m_id = int(float(movie_id))
profile = recommender.get_movie_detailed_profile(m_id)
if profile is not None:
details_html = build_movie_details_html(profile)
dist_fig = get_single_movie_rating_distribution(profile["distribution"], profile["title"])
time_fig = get_single_movie_popularity_timeline(profile["timeline"], profile["title"])
return details_html, dist_fig, time_fig
except Exception as e:
print(f"Error loading profile for movie ID {movie_id}: {e}")
return build_movie_details_html(None), go.Figure(), go.Figure()
# Section 2: DeepFM Recommendations Callback
def generate_recommendations(user_id_str, top_n_str):
"""
Runs DeepFM prediction model or retrieves cached recommendations for the User.
Also returns metrics KPIs and Plotly analytics histograms.
"""
try:
user_id = int(user_id_str.split()[0]) # handle spaces or "New User"
except ValueError:
user_id = 6 # fallback default user
try:
top_n = int(top_n_str)
except ValueError:
top_n = 10
# Generate recommendations using recommender module
try:
recs_df, is_cold = recommender.recommend_for_user(user_id, top_n=top_n)
except Exception as e:
print(f"Recommendation error: {e}", flush=True)
return (
f"Error generating recommendations: {e}
",
"Metrics unavailable
",
go.Figure(),
"Error occurred
"
)
# Calculate metrics
metrics = recommender.get_recommendation_analytics(recs_df)
# Render outputs
grid_html = build_recommendations_grid_html(recs_df)
metrics_html = f"""
{metrics['avg_confidence']:.1f}%
Model Confidence
{metrics['diversity_score']:.1f}%
Diversity Index
{metrics['personalization_score']:.1f}%
Personalization Fit
"""
# Update Plotly Chart
fig = get_predictions_score_chart(recs_df)
rec_type_badge = "Cold Start Recommendation (Popular Content)" if is_cold else "DeepFM Neural Recommendation Model"
explanation_banner = f"""
Recommendation Mode: {rec_type_badge}
This profile is built dynamically by combining factorization low-order mappings with high-order neural net predictions.
"""
return grid_html, metrics_html, fig, explanation_banner
# Section 3: Similar Movies Autocomplete & Search Callback
def check_search_match_type(query, valid_rows):
if not valid_rows:
return "NO_MATCH", None
query_clean = query.strip().lower()
# 1. Exact Match (case-insensitive)
for title, year_str, movie_id, choice_str in valid_rows:
if title.strip().lower() == query_clean:
return "EXACT_MATCH", (title, year_str, movie_id, choice_str)
# 2. One Match
if len(valid_rows) == 1:
title, year_str, movie_id, choice_str = valid_rows[0]
title_clean = title.strip().lower()
if (query_clean in title_clean or title_clean in query_clean) and len(query_clean) >= 0.5 * len(title_clean):
return "ONE_MATCH", (title, year_str, movie_id, choice_str)
return "MULTIPLE_MATCHES", None
def handle_similar_search_change(query, similar_count_str="10"):
search_query = query
print(f"--- Similar Movies Search Triggered ---")
print(f"search_query: {repr(search_query)}")
# Initialize default outputs
empty_dropdown = gr.update(choices=[], value=None, label="Select Matching Movie")
no_results_grid = "Search and select a movie above, then click Find Similar.
"
empty_graph = go.Figure()
empty_banner = ""
if not query or len(query.strip()) < 2:
return empty_dropdown, no_results_grid, empty_graph, empty_banner
try:
top_n = int(similar_count_str)
except Exception:
top_n = 10
try:
# Call backend search
raw_res = fuzzy_search_movies(df_raw, query)
if isinstance(raw_res, tuple):
raw_matches, search_info = raw_res
else:
raw_matches = raw_res
search_info = {}
match_count = len(raw_matches)
dataframe_columns = list(raw_matches.columns)
print(f"raw_matches type: {type(raw_matches)}")
print(f"match_count: {match_count}")
print(f"dataframe_columns: {dataframe_columns}")
# Validate Dropdown Data Structure safely (remove None, NaN, missing title, missing movie_id)
valid_rows = []
dropdown_options = []
for idx, row in raw_matches.iterrows():
title = row.get("title")
movie_id = row.get("movie_id")
year = row.get("year")
if pd.isna(title) or title is None or str(title).strip() == "":
continue
if pd.isna(movie_id) or movie_id is None:
continue
try:
movie_id = int(movie_id)
except Exception:
continue
try:
year_val = int(year) if not pd.isna(year) and year is not None else 0
year_str = f" ({year_val})" if year_val > 0 else ""
except Exception:
year_str = ""
choice_str = f"{title}{year_str} | ID: {movie_id}"
dropdown_options.append(choice_str)
valid_rows.append((title, year_str, movie_id, choice_str))
print(f"dropdown_options: {dropdown_options}")
if not valid_rows:
print("No matching movies found.")
return (
gr.update(choices=[], value=None, label="Select Matching Movie (No matching movies found.)"),
"No matching movies found.
",
go.Figure(),
""
)
# Determine Match Type
match_type, matched_info = check_search_match_type(query, valid_rows)
print(f"Determined match_type: {match_type}")
if match_type in ("EXACT_MATCH", "ONE_MATCH"):
# Auto-select movie
title, year_str, movie_id, choice_str = matched_info
selected_movie_id = movie_id
selected_movie = raw_matches[raw_matches["movie_id"] == movie_id].iloc[0].to_dict()
print(f"Auto-selecting movie: {title}")
print(f"selected_movie_id: {selected_movie_id}")
print(f"selected_movie: {selected_movie}")
# Similarity Pipeline Validation
try:
sims_df = recommender.get_similar_movies(selected_movie_id, top_n=top_n)
grid_html = build_similar_grid_html(sims_df)
graph_fig = get_movie_relationship_graph(selected_movie, sims_df)
explanation_header = f"""
"""
return (
gr.update(choices=dropdown_options, value=choice_str, label="Select Matching Movie"),
grid_html,
graph_fig,
explanation_header
)
except Exception as sim_err:
print(f"Similarity pipeline error for movie ID {selected_movie_id}: {sim_err}")
traceback.print_exc()
err_html = f"""
Similarity Pipeline Error
Failed to find similar movies for {selected_movie['title']}.
{traceback.format_exc()}
"""
return (
gr.update(choices=dropdown_options, value=choice_str, label="Select Matching Movie"),
err_html,
go.Figure(),
""
)
else:
# MULTIPLE_MATCHES
print(f"Multiple matches found. Showing selector with {len(dropdown_options)} options.")
return (
gr.update(choices=dropdown_options, value=dropdown_options[0], label="Select Matching Movie"),
"Multiple matches found. Select a movie and click Find Similar.
",
go.Figure(),
""
)
except Exception as e:
print(f"Exception in search change handler: {e}")
traceback.print_exc()
err_html = f"""
Search Handler Error
{traceback.format_exc()}
"""
return gr.update(choices=[], value=None, label="Search Error"), err_html, go.Figure(), ""
def find_similar_movies(choice_str, top_n_str):
"""
Runs vector similarity against DeepFM latent embeddings for the selected movie.
"""
print(f"--- Find Similar Movies Clicked ---")
print(f"choice_str: {repr(choice_str)}")
print(f"top_n_str: {repr(top_n_str)}")
if not choice_str:
return (
"Please search and select a movie above.
",
go.Figure(),
""
)
try:
selected_movie_id = int(choice_str.split("| ID: ")[-1])
top_n = int(top_n_str)
except Exception as e:
print(f"Error parsing similar movie choice: {e}")
traceback.print_exc()
err_html = f"""
Parsing Error
Failed to parse the selected movie ID.
{traceback.format_exc()}
"""
return err_html, go.Figure(), ""
ref_match = df_raw[df_raw["movie_id"] == selected_movie_id]
if ref_match.empty:
return (
f"Movie with ID {selected_movie_id} not found in catalog.
",
go.Figure(),
""
)
ref_movie = ref_match.iloc[0]
selected_movie = ref_movie.to_dict()
print(f"selected_movie_id: {selected_movie_id}")
print(f"selected_movie: {selected_movie}")
# Calculate similarities
try:
sims_df = recommender.get_similar_movies(selected_movie_id, top_n=top_n)
grid_html = build_similar_grid_html(sims_df)
graph_fig = get_movie_relationship_graph(ref_movie, sims_df)
explanation_header = f"""
"""
return grid_html, graph_fig, explanation_header
except Exception as e:
print(f"Similar movies error: {e}", flush=True)
traceback.print_exc()
err_html = f"""
Similarity Pipeline Error
Failed to find similar movies for {ref_movie['title']}.
{traceback.format_exc()}
"""
return err_html, go.Figure(), ""
# Build Gradio Blocks Interface
with gr.Blocks(title="CineMind AI - Premium Movie Discovery") as demo:
# Sticky Navigation Header
gr.HTML(NAVBAR_HTML)
# Hidden components for Svelte state management (rendered but styled as hidden)
state_filtered_df = gr.State(df_raw)
state_current_page = gr.State(0)
hidden_movie_select = gr.Textbox(elem_id="hidden_movie_select", elem_classes="hidden-textbox", visible=True)
# Home Section Target
gr.HTML('')
# Cinematic Hero Section
hero_html = f"""
AI-Powered Movie Discovery Platform
Find Your Next Favorite Movie
Discover movies using advanced recommendation intelligence powered by DeepFM.
"""
gr.HTML(hero_html)
# Movie Details Section Target (Global scroll target for clicks)
gr.HTML('')
# Tabs Navigation
with gr.Tabs(elem_classes="tabs"):
# TAB 1: đŦ Recommendations
with gr.Tab("đŦ Recommendations"):
gr.HTML("""
""")
# Control Inputs for Recommendations
with gr.Row(elem_classes="filter-row"):
user_id_dropdown = gr.Dropdown(
choices=["6", "7", "8", "10", "33", "42", "59", "79", "83", "87", "94", "97", "116", "131", "158", "164"],
value="6",
label="Select User Profile ID",
interactive=True
)
recommendation_count = gr.Dropdown(
choices=["5", "10", "20", "50"],
value="10",
label="Recommendation Count",
interactive=True
)
btn_rec_generate = gr.Button("Generate Recommendations", variant="primary", elem_classes="primary-btn")
# Explanation indicator
recs_mode_banner = gr.HTML(value="")
# Recommendation output grid
recs_grid_output = gr.HTML(value="Select a profile above and click 'Generate Recommendations' to load predicted content.
")
# Recommendation analytics
gr.HTML("""
""")
recs_metrics_output = gr.HTML(value="")
with gr.Row():
recs_chart_output = gr.Plot(label="Score Distribution")
# TAB 2: đ Similar Movies (Section 3 Integration)
with gr.Tab("đ Similar Movies"):
gr.HTML("""
""")
with gr.Row(elem_classes="filter-row"):
with gr.Column(scale=3):
similar_search = gr.Textbox(
placeholder="Type movie name to search (e.g. Dinosaur, Inception, Lord)...",
label="Search Movie Title",
interactive=True
)
with gr.Column(scale=2):
similar_select = gr.Dropdown(
choices=[],
label="Select Matching Movie",
interactive=True
)
with gr.Column(scale=1):
similar_count = gr.Dropdown(
choices=["5", "10", "15", "20"],
value="10",
label="Find Count",
interactive=True
)
btn_similar_find = gr.Button("Find Similar", variant="primary", elem_classes="primary-btn")
# Dynamic header display
similar_ref_banner = gr.HTML(value="")
# Similar movies output grid
similar_grid_output = gr.HTML(value="Search and select a movie above, then click Find Similar.
")
# Plotly relationship graph
gr.HTML("""
""")
similar_graph_output = gr.Plot(label="Relationship Network")
# TAB 3: âšī¸ About
with gr.Tab("âšī¸ About"):
about_html = f"""
About CineMind AI
CineMind AI is a premium movie discovery platform powered by a production-grade DeepFM (Deep Factorization Machine) recommendation engine.
By combining the strength of factorization machines for low-order feature interactions with deep neural networks for high-order interactions,
the engine predicts user preferences with exceptional accuracy.
Dataset Statistics
{kpis['total_movies']:,}
Movies Cataloged
340,955
Implicit Profiles
23,105,815
Interaction Signals
"""
gr.HTML(about_html)
# TAB 4: đ Diagnostics
with gr.Tab("đ Diagnostics"):
btn_refresh_diag = gr.Button("Refresh System Diagnostics", variant="secondary", elem_classes="secondary-btn")
diagnostics_panel = gr.HTML(value=get_diagnostics_html(recommender, poster_service))
# Section 4 Movie Insights panel & timeline charts
gr.HTML("""
Selected Movie Insights
Click any card in the Explorer Catalog or Trending lists to load deep analytics, consensus deviation, and timelines.
""")
with gr.Column(elem_classes="stats-dashboard"):
details_panel = gr.HTML(value=build_movie_details_html(None))
with gr.Row():
single_movie_dist_plot = gr.Plot(label="Star Rating Distribution")
single_movie_time_plot = gr.Plot(label="Cumulative Votes Growth")
# Explore Section Target
gr.HTML('')
# Explore/Catalog Section
gr.HTML("""
""")
# Search and Filter Form (Section 1 Refinements)
with gr.Row(elem_classes="filter-row"):
with gr.Column(scale=3):
search_box = gr.Textbox(
placeholder="Search by movie title or keywords...",
label="Search Movies",
max_lines=1,
interactive=True
)
with gr.Column(scale=2):
sort_dropdown = gr.Dropdown(
choices=["Highest Rated", "Most Popular", "Newest", "Oldest", "Alphabetical"],
value="Most Popular",
label="Sort By",
interactive=True
)
with gr.Row(elem_classes="filter-row"):
with gr.Column(scale=1):
year_min_slider = gr.Slider(
minimum=min_year,
maximum=max_year,
value=min_year,
step=1,
label="Start Year",
interactive=True
)
with gr.Column(scale=1):
year_max_slider = gr.Slider(
minimum=min_year,
maximum=max_year,
value=max_year,
step=1,
label="End Year",
interactive=True
)
with gr.Column(scale=1):
rating_slider = gr.Slider(
minimum=0.0,
maximum=5.0,
value=0.0,
step=0.5,
label="Minimum Average Rating",
interactive=True
)
with gr.Column(scale=1):
votes_slider = gr.Slider(
minimum=0,
maximum=10000,
value=0,
step=100,
label="Minimum Vote Count",
interactive=True
)
# Interactive Catalog Grid
init_filtered, _ = filter_and_sort_movies(df_raw, "", [min_year, max_year], 0.0, 0, "Most Popular")
init_sliced, init_total_pages = paginate_dataframe(init_filtered, 0, PAGE_SIZE)
catalog_grid = gr.HTML(build_movie_grid_html(init_sliced))
# Pagination Row
with gr.Row(elem_classes="pagination-container"):
btn_prev = gr.Button("â Previous", variant="secondary", interactive=False)
page_indicator = gr.HTML(f"{get_page_label(0, init_total_pages)}
")
btn_next = gr.Button("Next â", variant="secondary", interactive=init_total_pages > 1)
# Trending Section Target
gr.HTML('')
# Trending Section
gr.HTML("""
""")
gr.HTML(build_movie_grid_html(trending_df))
# Statistics Section Target
gr.HTML('')
# Statistics Dashboard
gr.HTML("""
""")
with gr.Column(elem_classes="stats-dashboard"):
# KPI widgets
gr.HTML(f"""
{kpis['total_movies']:,}
Total Titles
{kpis['avg_rating']:.2f} â
Average Rating
{kpis['avg_votes']:,}
Average Votes
{kpis['most_popular']}
Most Popular Title
{kpis['newest_movie']}
Newest Release
""")
# Distribution charts
with gr.Row():
chart_rating = gr.Plot(get_rating_distribution_chart(df_raw))
chart_year = gr.Plot(get_year_distribution_chart(df_raw))
# Sticky Footer
gr.HTML(FOOTER_HTML)
# Bind callbacks
# Bind Filter inputs (dual year sliders) to catalog grid updates
filter_inputs = [search_box, year_min_slider, year_max_slider, rating_slider, votes_slider, sort_dropdown]
filter_outputs = [state_filtered_df, state_current_page, catalog_grid, page_indicator, btn_prev, btn_next]
# Bind change listeners
search_box.change(fn=filter_catalog, inputs=filter_inputs, outputs=filter_outputs)
year_min_slider.change(fn=filter_catalog, inputs=filter_inputs, outputs=filter_outputs)
year_max_slider.change(fn=filter_catalog, inputs=filter_inputs, outputs=filter_outputs)
rating_slider.change(fn=filter_catalog, inputs=filter_inputs, outputs=filter_outputs)
votes_slider.change(fn=filter_catalog, inputs=filter_inputs, outputs=filter_outputs)
sort_dropdown.change(fn=filter_catalog, inputs=filter_inputs, outputs=filter_outputs)
# Pagination button clicks
btn_prev.click(
fn=change_page,
inputs=[gr.State(-1), state_current_page, state_filtered_df],
outputs=[state_current_page, catalog_grid, page_indicator, btn_prev, btn_next]
)
btn_next.click(
fn=change_page,
inputs=[gr.State(1), state_current_page, state_filtered_df],
outputs=[state_current_page, catalog_grid, page_indicator, btn_prev, btn_next]
)
# Selection details bindings (Wired to details panel HTML and Plotly graphs)
hidden_movie_select.change(
fn=select_movie,
inputs=hidden_movie_select,
outputs=[details_panel, single_movie_dist_plot, single_movie_time_plot]
)
# Section 2: DeepFM Recommendations trigger bindings
btn_rec_generate.click(
fn=generate_recommendations,
inputs=[user_id_dropdown, recommendation_count],
outputs=[recs_grid_output, recs_metrics_output, recs_chart_output, recs_mode_banner]
)
# Section 3: Similar Movies autocomplete & find trigger bindings
similar_search.change(
fn=handle_similar_search_change,
inputs=[similar_search, similar_count],
outputs=[similar_select, similar_grid_output, similar_graph_output, similar_ref_banner]
)
btn_similar_find.click(
fn=find_similar_movies,
inputs=[similar_select, similar_count],
outputs=[similar_grid_output, similar_graph_output, similar_ref_banner]
)
# Diagnostics refresh binding
btn_refresh_diag.click(
fn=lambda: get_diagnostics_html(recommender, poster_service),
inputs=[],
outputs=diagnostics_panel
)
if __name__ == "__main__":
demo.launch(css=CUSTOM_CSS, server_port=7860)