import json import logging import os import uuid from datetime import datetime from typing import Optional import numpy as np import pandas as pd import streamlit as st import sys # --- PATH SETUP --- # Add the project root to the Python path sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) # --- MODULE IMPORTS --- # (Assumed to exist based on your previous code) import src.book_recommender.core.config as config from src.book_recommender.core.exceptions import DataNotFoundError from src.book_recommender.core.logging_config import configure_logging from src.book_recommender.data.processor import clean_and_prepare_data from src.book_recommender.ml.clustering import cluster_books, get_cluster_names from src.book_recommender.ml.embedder import generate_embedding_for_query from src.book_recommender.ml.explainability import explain_recommendation from src.book_recommender.ml.feedback import save_feedback from src.book_recommender.ml.recommender import BookRecommender from src.book_recommender.utils import get_cover_url_multi_source, load_book_covers_batch # --- CONFIGURATION --- configure_logging(log_file="app.log", log_level=os.getenv("LOG_LEVEL", "INFO")) logger = logging.getLogger(__name__) st.set_page_config( page_title="DeepShelf Demo", page_icon="📚", layout="wide", initial_sidebar_state="collapsed" ) # --- MODERN CSS (V2) --- st.markdown( """ """, unsafe_allow_html=True, ) # --- BACKEND FUNCTIONS (Cached) --- @st.cache_resource(show_spinner=False) def load_recommender() -> BookRecommender: """Load recommender efficiently.""" files_exist = ( os.path.exists(config.PROCESSED_DATA_PATH) and os.path.exists(config.EMBEDDINGS_PATH) and os.path.exists(config.EMBEDDING_METADATA_PATH) ) model_changed = False if files_exist: with open(config.EMBEDDING_METADATA_PATH, "r", encoding="utf-8") as f: metadata = json.load(f) if metadata.get("model_name") != config.EMBEDDING_MODEL: model_changed = True if files_exist and not model_changed: book_data = pd.read_parquet(config.PROCESSED_DATA_PATH) embeddings = np.load(config.EMBEDDINGS_PATH) else: # Fallback to generation if files missing (usually dev env) from src.book_recommender.ml.embedder import generate_embeddings if not os.path.exists(config.RAW_DATA_PATH): raise DataNotFoundError(f"Raw data file not found at: {config.RAW_DATA_PATH}") book_data = clean_and_prepare_data(str(config.RAW_DATA_PATH), str(config.PROCESSED_DATA_PATH)) embeddings = generate_embeddings(book_data, model_name=config.EMBEDDING_MODEL, show_progress_bar=False) np.save(config.EMBEDDINGS_PATH, embeddings) metadata = {"model_name": config.EMBEDDING_MODEL} with open(config.EMBEDDING_METADATA_PATH, "w", encoding="utf-8") as f: json.dump(metadata, f) return BookRecommender(book_data=book_data, embeddings=embeddings) @st.cache_resource(show_spinner=False) def load_cluster_data() -> tuple[np.ndarray, dict, pd.DataFrame]: """Load cluster data for the 'Browse' tab.""" recommender = load_recommender() book_data_df = recommender.book_data.copy() embeddings_arr = recommender.embeddings clusters_arr, _ = cluster_books(embeddings_arr, n_clusters=config.NUM_CLUSTERS) book_data_df["cluster_id"] = clusters_arr names = get_cluster_names(book_data_df, clusters_arr) return clusters_arr, names, book_data_df # --- UI COMPONENT FUNCTIONS --- def render_hero(): """Renders the top Hero section.""" st.markdown("""

📚 DeepShelf Demo

Describe what you're craving. We'll handle the rest.

""", unsafe_allow_html=True) def render_book_card_content(rec, cover_url): """ Renders the HTML content of the card (Image + Text). Buttons are handled outside by Streamlit to maintain functionality. """ # Process Genres (Limit to 2 for space) genres_html = "" if rec.get("genres") and isinstance(rec.get("genres"), str): genres = [g.strip() for g in rec["genres"].split(",")[:2]] for g in genres: genres_html += f'{g.title()}' # Process Similarity match_badge = "" if "similarity" in rec: pct = int(rec["similarity"] * 100) match_badge = f'
{pct}% Match
' # Rating rating_display = "" if rec.get("rating") and pd.notna(rec["rating"]): try: r = float(rec["rating"]) rating_display = f'★ {r:.1f}' except: pass # HTML Structure html = f"""
{match_badge}
{rec['title']}
by {rec.get('authors', 'Unknown')}
{rating_display}
{genres_html}
""" st.markdown(html, unsafe_allow_html=True) @st.dialog("📖 Book Details") def show_book_details(book, query_text: Optional[str] = None): """Modal for book details.""" # Header st.subheader(book['title']) st.caption(f"by {book.get('authors', 'Unknown Author')}") col1, col2 = st.columns([1, 2], gap="large") with col1: st.image(get_cover_url_multi_source(book["title"], book.get("authors", "")), use_container_width=True) # Metadata pills if book.get("rating"): st.metric("Rating", f"{float(book['rating']):.1f}/5") with col2: # AI Explanation if query_text and "similarity" in book: with st.spinner("Analyzing match..."): explanation = explain_recommendation( query_text=query_text, recommended_book=book, similarity_score=book["similarity"] ) st.markdown(f"**Why this matches:**") st.info(explanation["summary"]) # Description st.markdown("**Synopsis:**") st.write(book.get("description", "No description available.")) st.divider() # External Links c1, c2 = st.columns(2) c1.link_button("Google Search", f"https://www.google.com/search?q={book['title']}+{book.get('authors', '')}", use_container_width=True) c2.link_button("Goodreads", f"https://www.goodreads.com/search?q={book['title']}", use_container_width=True) # --- MAIN APP LOGIC --- def main(): # Session State Init if "query" not in st.session_state: st.session_state.query = "" if "recommendations" not in st.session_state: st.session_state.recommendations = [] if "session_id" not in st.session_state: st.session_state.session_id = str(uuid.uuid4()) try: recommender = load_recommender() # --- STICKY HERO & SEARCH --- # We put this in a container to act as our sticky header with st.container(): render_hero() # Search Input Area c1, c2, c3 = st.columns([1, 6, 1]) with c2: # Suggestion Chips s1, s2, s3 = st.columns(3) if s1.button("🧙‍♂️ Fantasy Dragons", use_container_width=True): st.session_state.query = "Epic fantasy with dragons and magic" if s2.button("🕵️‍♀️ Murder Mystery", use_container_width=True): st.session_state.query = "Whodunnit mystery thriller" if s3.button("🚀 Sci-Fi Space", use_container_width=True): st.session_state.query = "Hard science fiction in space" query_input = st.text_area( "Search", value=st.session_state.query, placeholder="Describe the vibe: 'A melancholic book about growing up in the 90s...'", height=80, label_visibility="collapsed" ) search_clicked = st.button("✨ Find Recommendations", type="primary", use_container_width=True) # --- LOGIC HANDLER --- if search_clicked and query_input: st.session_state.query = query_input # Sync state with st.spinner("Reading thousands of books..."): query_embedding = generate_embedding_for_query(query_input) st.session_state.recommendations = recommender.get_recommendations_from_vector( query_embedding, top_k=12, similarity_threshold=0.20 ) st.divider() # --- RESULTS GRID --- if st.session_state.recommendations: st.markdown(f"### Found {len(st.session_state.recommendations)} Matches") # Fetch covers in bulk visible_recs = st.session_state.recommendations with st.spinner("Fetching covers..."): covers_dict = load_book_covers_batch(visible_recs) # Responsive Grid Logic # We iterate through the results and create rows of 4 num_cols = 4 rows = [visible_recs[i:i + num_cols] for i in range(0, len(visible_recs), num_cols)] for row in rows: cols = st.columns(num_cols) for idx, (col, rec) in enumerate(zip(cols, row)): with col: # 1. Render the HTML visual card cover = covers_dict.get(rec["title"], config.FALLBACK_COVER_URL) render_book_card_content(rec, cover) # 2. Render the Streamlit interactive buttons BELOW the HTML card # Because of the fixed height in CSS, these will align nicely b1, b2 = st.columns([1, 3]) with b1: # Like button (icon only) st.button("👍", key=f"like_{rec['title']}", help="More like this") with b2: if st.button("View Details", key=f"det_{rec['title']}", use_container_width=True): show_book_details(rec, st.session_state.query) st.markdown("
", unsafe_allow_html=True) elif search_clicked: st.warning("No books found matching that description. Try being broader!") except Exception as e: st.error(f"App Error: {str(e)}") logger.error(f"Crash: {e}", exc_info=True) if __name__ == "__main__": main()