nice-bill commited on
Commit
cdb73a8
·
1 Parent(s): 33b3d0a

initial commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .devcontainer/devcontainer.json +33 -0
  2. .dockerignore +31 -0
  3. .env.example +10 -0
  4. .gitattributes +2 -33
  5. .gitignore +85 -0
  6. .python-version +1 -0
  7. .streamlit/config.toml +9 -0
  8. DEPLOYMENT_NOTES.md +69 -0
  9. Dockerfile.backend +74 -0
  10. Makefile +53 -0
  11. README.md +162 -1
  12. data/feedback/user_feedback.jsonl +4 -0
  13. docs/ARCHITECTURE.md +176 -0
  14. frontend/.gitignore +24 -0
  15. frontend/README.md +73 -0
  16. frontend/eslint.config.js +23 -0
  17. frontend/index.html +16 -0
  18. frontend/package-lock.json +0 -0
  19. frontend/package.json +38 -0
  20. frontend/postcss.config.js +6 -0
  21. frontend/public/vite.svg +1 -0
  22. frontend/src/App.css +42 -0
  23. frontend/src/App.tsx +616 -0
  24. frontend/src/BookCard.tsx +122 -0
  25. frontend/src/BookCover.tsx +75 -0
  26. frontend/src/Loader.tsx +50 -0
  27. frontend/src/api.ts +59 -0
  28. frontend/src/assets/react.svg +1 -0
  29. frontend/src/data/personas.ts +65 -0
  30. frontend/src/index.css +136 -0
  31. frontend/src/main.tsx +10 -0
  32. frontend/src/types.ts +25 -0
  33. frontend/tailwind.config.js +12 -0
  34. frontend/tsconfig.app.json +28 -0
  35. frontend/tsconfig.json +7 -0
  36. frontend/tsconfig.node.json +26 -0
  37. frontend/vite.config.ts +16 -0
  38. logs/.gitkeep +0 -0
  39. pyproject.toml +106 -0
  40. requirements-dev.txt +9 -0
  41. requirements.backend.txt +17 -0
  42. requirements.txt +19 -0
  43. scripts/download_data.py +82 -0
  44. scripts/download_model.py +22 -0
  45. scripts/enrich_book_covers.py +81 -0
  46. scripts/precompute_clusters.py +56 -0
  47. scripts/prepare_100k_data.py +71 -0
  48. scripts/prepare_goodreads_data.py +104 -0
  49. src/__init__.py +0 -0
  50. src/book_recommender/__init__.py +23 -0
.devcontainer/devcontainer.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Python 3",
3
+ // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
4
+ "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
5
+ "customizations": {
6
+ "codespaces": {
7
+ "openFiles": [
8
+ "README.md",
9
+ "src/book_recommender/apps/main_app.py"
10
+ ]
11
+ },
12
+ "vscode": {
13
+ "settings": {},
14
+ "extensions": [
15
+ "ms-python.python",
16
+ "ms-python.vscode-pylance"
17
+ ]
18
+ }
19
+ },
20
+ "updateContentCommand": "[ -f packages.txt ] && sudo apt update && sudo apt upgrade -y && sudo xargs apt install -y <packages.txt; [ -f requirements.txt ] && pip3 install --user -r requirements.txt; pip3 install --user streamlit; echo '✅ Packages installed and Requirements met'",
21
+ "postAttachCommand": {
22
+ "server": "streamlit run src/book_recommender/apps/main_app.py --server.enableCORS false --server.enableXsrfProtection false"
23
+ },
24
+ "portsAttributes": {
25
+ "8501": {
26
+ "label": "Application",
27
+ "onAutoForward": "openPreview"
28
+ }
29
+ },
30
+ "forwardPorts": [
31
+ 8501
32
+ ]
33
+ }
.dockerignore ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .Python
6
+ env/
7
+ venv/
8
+ .venv/
9
+ pip-log.txt
10
+ pip-delete-this-directory.txt
11
+ .tox/
12
+ .coverage
13
+ .coverage.*
14
+ .cache
15
+ nosetests.xml
16
+ coverage.xml
17
+ *.cover
18
+ *.log
19
+ .git
20
+ .mypy_cache
21
+ .pytest_cache
22
+ .ruff_cache
23
+
24
+ # Data (Except processed)
25
+ data/raw
26
+ data/feedback
27
+ !data/processed
28
+
29
+ # Frontend (Don't copy frontend code into backend image)
30
+ frontend/
31
+ node_modules/
.env.example ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # API Keys
2
+ GROQ_API_KEY=your_groq_api_key_here
3
+ GOOGLE_BOOKS_API_KEY=your_google_books_key_optional
4
+
5
+ # App Config
6
+ LOG_LEVEL=INFO
7
+ PORT=8000
8
+ PERSONALIZER_URL=http://localhost:8001 # Or your Hugging Face Space URL
9
+
10
+ HF_DATASET_ID=nice-bill/book-recommender-data
.gitattributes CHANGED
@@ -1,35 +1,4 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
  *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
  *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
2
  *.npy filter=lfs diff=lfs merge=lfs -text
 
 
 
3
  *.parquet filter=lfs diff=lfs merge=lfs -text
4
+ *.csv filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+
24
+ # Virtual Environments
25
+ .venv/
26
+ venv/
27
+ ENV/
28
+ env/
29
+
30
+ # IDE
31
+ .vscode/
32
+ .idea/
33
+ *.swp
34
+ *.swo
35
+ *~
36
+
37
+ # Jupyter
38
+ .ipynb_checkpoints/
39
+ *.ipynb
40
+
41
+ # Data (but keep structure)
42
+ data/raw/*.csv
43
+ data/processed/*.parquet
44
+ data/processed/*.npy
45
+ data/processed/*.pkl
46
+ data/processed/model_cache/
47
+ data/processed/embedding_metadata.json
48
+ !data/raw/.gitkeep
49
+ !data/processed/.gitkeep
50
+
51
+ # Logs
52
+ logs/*.log
53
+ !logs/.gitkeep
54
+
55
+ # Environment
56
+ .env
57
+ .env.local
58
+
59
+ # OS
60
+ .DS_Store
61
+ Thumbs.db
62
+
63
+ # Testing
64
+ .coverage
65
+ .pytest_cache/
66
+ htmlcov/
67
+ .tox/
68
+
69
+ # CI/CD
70
+ .github/workflows/*.log
71
+
72
+ # Keep directory structure
73
+ !**/.gitkeep
74
+
75
+ # Existing rules from before
76
+ docs/gemini.md
77
+ scripts/Advanced Features To Consider.txt
78
+ scripts/Book_Recommender_Project_SEC.txt
79
+
80
+ .ruff_cache/
81
+ .mypy_cache/
82
+
83
+ run_analytics.bat
84
+ run_api.bat
85
+ run_app.bat
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.12
.streamlit/config.toml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # .streamlit/config.toml
2
+
3
+ [theme]
4
+ base="dark"
5
+ primaryColor="#FF4B4B"
6
+ backgroundColor="#0E1117"
7
+ secondaryBackgroundColor="#262730"
8
+ textColor="#FAFAFA"
9
+ font="sans serif"
DEPLOYMENT_NOTES.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deployment Notes & Configuration Guide
2
+
3
+ *Created on: November 30, 2025*
4
+ *Project Status: MVP Complete / Production Ready*
5
+
6
+ This document serves as a "black box" recovery manual. If you return to this project in the future and need to redeploy it, follow these exact steps.
7
+
8
+ ---
9
+
10
+ ## 1. Environment Variables
11
+ These are the variables you MUST set in your cloud provider (Render/Vercel/Railway) for the app to function.
12
+
13
+ ### **Backend (FastAPI / Docker)**
14
+ *Deployment Platform: Render (Web Service) or Railway*
15
+
16
+ | Variable Name | Value (Example/Format) | Purpose |
17
+ | :--- | :--- | :--- |
18
+ | `HF_DATASET_ID` | `nice-bill/book-recommender-data` | **CRITICAL.** Tells the app where to download the embeddings/parquet files from Hugging Face. |
19
+ | `GROQ_API_KEY` | `gsk_...` | Required for the "Why this match?" AI explanations. Get a key at [console.groq.com](https://console.groq.com). |
20
+ | `HF_TOKEN` | `hf_...` | *(Optional)* Only required if you made your Hugging Face dataset PRIVATE. |
21
+ | `PORT` | `8000` | (Render sets this automatically, but good to know). |
22
+
23
+ ### **Frontend (React / Vite)**
24
+ *Deployment Platform: Vercel or Netlify*
25
+
26
+ | Variable Name | Value (Example/Format) | Purpose |
27
+ | :--- | :--- | :--- |
28
+ | `VITE_API_URL` | `https://your-app-name.onrender.com` | Points the frontend to your live backend. **Do not add a trailing slash.** |
29
+
30
+ ---
31
+
32
+ ## 2. Build Settings
33
+
34
+ ### **Backend**
35
+ * **Runtime:** Docker
36
+ * **Dockerfile Path:** `docker/Dockerfile.backend`
37
+ * **Context Directory:** `.` (Root of the repo)
38
+ * **Start Command:** (Handled automatically by Dockerfile: `scripts/download_data.py && uvicorn ...`)
39
+
40
+ ### **Frontend**
41
+ * **Framework Preset:** Vite
42
+ * **Root Directory:** `frontend`
43
+ * **Build Command:** `npm run build`
44
+ * **Output Directory:** `dist`
45
+ * **Node Version:** 18.x or 20.x
46
+
47
+ ---
48
+
49
+ ## 3. "Cold Start" Recovery
50
+ If the application is crashing on startup after a long time:
51
+
52
+ 1. **Check Hugging Face Data:**
53
+ * Go to: `https://huggingface.co/datasets/nice-bill/book-recommender-data`
54
+ * Ensure these 4 files still exist: `books_cleaned.parquet`, `book_embeddings.npy`, `cluster_cache.pkl`, `embedding_metadata.json`.
55
+
56
+ 2. **Check Groq API:**
57
+ * API keys sometimes expire or quotas change. Generate a new one if the "Why this match?" feature fails.
58
+
59
+ 3. **Local Run:**
60
+ * Backend: `uv run src/book_recommender/api/main.py`
61
+ * Frontend: `cd frontend && npm run dev`
62
+
63
+ ---
64
+
65
+ ## 4. Key Features Summary
66
+ * **Search:** Semantic search using `all-MiniLM-L6-v2` embeddings.
67
+ * **Explanation:** Uses Llama 3 (via Groq) to explain recommendations.
68
+ * **Data Layer:** Self-healing. Downloads data on boot if missing.
69
+ * **UI:** React + Tailwind + Lucide Icons. Features "Glassmorphism" and tactile hover effects.
Dockerfile.backend ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build Stage
2
+ FROM python:3.10-slim AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ ENV PYTHONDONTWRITEBYTECODE=1
7
+ ENV PYTHONUNBUFFERED=1
8
+
9
+ # Install build dependencies
10
+ RUN apt-get update && apt-get install -y \
11
+ gcc \
12
+ python3-dev \
13
+ curl \
14
+ git \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ # Install uv
18
+ RUN pip install --no-cache-dir uv
19
+
20
+ COPY requirements.backend.txt .
21
+
22
+ # Create virtual environment and install dependencies
23
+ ENV UV_HTTP_TIMEOUT=300
24
+ RUN uv venv .venv && \
25
+ uv pip install --no-cache -r requirements.backend.txt --extra-index-url https://download.pytorch.org/whl/cpu
26
+
27
+ # --- Runtime Stage ---
28
+ FROM python:3.10-slim
29
+
30
+ WORKDIR /app
31
+
32
+ ENV PYTHONDONTWRITEBYTECODE=1
33
+ ENV PYTHONUNBUFFERED=1
34
+ ENV PATH="/app/.venv/bin:$PATH"
35
+ ENV LANG=C.UTF-8
36
+ ENV LC_ALL=C.UTF-8
37
+
38
+ # Install runtime dependencies (curl for healthcheck)
39
+ RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
40
+
41
+ # Copy virtual environment from builder
42
+ COPY --from=builder /app/.venv /app/.venv
43
+
44
+ # Copy Scripts
45
+ COPY scripts/ ./scripts/
46
+
47
+ # Data & Model Baking
48
+ ENV HF_HOME=/app/data/model_cache
49
+
50
+ # Download Model (Bake into image)
51
+ RUN /app/.venv/bin/python scripts/download_model.py
52
+
53
+ # Download Data (Bake into image)
54
+ RUN /app/.venv/bin/python scripts/download_data.py
55
+
56
+ # Copy Code
57
+ COPY src/ ./src/
58
+
59
+ # Create directories and permissions
60
+ RUN mkdir -p data/processed data/feedback logs
61
+ # addgroup --system app && adduser --system --group app && \
62
+ # chown -R app:app /app
63
+
64
+ # USER app
65
+
66
+ # Expose port
67
+ EXPOSE 7860
68
+
69
+ # Healthcheck
70
+ HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
71
+ CMD curl -f http://localhost:7860/health || exit 1
72
+
73
+ # Run Command
74
+ CMD ["/app/.venv/bin/uvicorn", "src.book_recommender.api.main:app", "--host", "0.0.0.0", "--port", "7860"]
Makefile ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: install test lint format clean run-web run-api run-analytics docker-build docker-up
2
+
3
+ install:
4
+ pip install -r requirements.txt
5
+ pip install -r requirements-dev.txt
6
+
7
+ install-dev:
8
+ pip install -e ".[dev]"
9
+
10
+ test:
11
+ pytest --cov=src --cov-report=html --cov-report=term
12
+
13
+ lint:
14
+ ruff check src/ tests/
15
+ black --check src/ tests/
16
+ mypy src/
17
+
18
+ format:
19
+ black src/ tests/
20
+ ruff check --fix src/ tests/
21
+
22
+ clean:
23
+ find . -type d -name "__pycache__" -exec rm -rf {} +
24
+ find . -type f -name "*.pyc" -delete
25
+ rm -rf .pytest_cache .coverage htmlcov dist build *.egg-info
26
+
27
+ run-web:
28
+ PYTHONPATH=. python -m streamlit run src/book_recommender/apps/main_app.py
29
+
30
+ run-api:
31
+ PYTHONPATH=. python -m uvicorn src.book_recommender.api.main:app --reload
32
+
33
+ run-analytics:
34
+ PYTHONPATH=. python -m streamlit run src/book_recommender/apps/analytics_app.py --server.port=8502
35
+
36
+ docker-build:
37
+ docker build -t bookfinder-ai:latest .
38
+
39
+ docker-up:
40
+ docker-compose up
41
+
42
+ docker-down:
43
+ docker-compose down
44
+
45
+ help:
46
+ @echo "Available commands:"
47
+ @echo " make install - Install dependencies"
48
+ @echo " make test - Run tests with coverage"
49
+ @echo " make lint - Run linters"
50
+ @echo " make format - Format code"
51
+ @echo " make run-web - Start Streamlit app"
52
+ @echo " make run-api - Start FastAPI server"
53
+ @echo " make docker-up - Start all services with Docker"
README.md CHANGED
@@ -9,4 +9,165 @@ license: mit
9
  short_description: Semantic Book Recommendation API powered by DeepShelf.
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  short_description: Semantic Book Recommendation API powered by DeepShelf.
10
  ---
11
 
12
+ # Serendipity (Book Discovery Platform)
13
+
14
+ ![Serendipity UI](https://img.shields.io/badge/Frontend-React_18-indigo) ![Backend](https://img.shields.io/badge/Backend-FastAPI-009688) ![Engine](https://img.shields.io/badge/AI_Engine-DeepShelf-blueviolet) ![Status](https://img.shields.io/badge/Status-Active-success)
15
+
16
+ **Serendipity** is a next-generation book discovery platform that moves beyond keyword matching. It uses **Semantic Search** and **Vector Embeddings** to understand the "vibe", plot, and emotional resonance of a book.
17
+
18
+ Powered by the **DeepShelf Engine**, it allows users to search for "a cyberpunk detective story set in Tokyo" and get results that match the *meaning*, not just the words.
19
+
20
+ ---
21
+
22
+ ## Key Features
23
+
24
+ ### The Experience (Frontend)
25
+ * **Semantic Search:** Type natural language queries like "sad books that feel like a rainy day."
26
+ * **Persona Picker (New!):** Experience the personalization engine by switching between pre-defined personas (e.g., "The Futurist", "The History Buff") to see how recommendations adapt to different reading histories.
27
+ * **Curated Clusters:** Explore automatically generated collections like "Space Opera", "Victorian Mystery", etc.
28
+ * **Explainability:** The AI explains *why* a book was recommended (e.g., "92% Plot Match", "Similar tone to your history").
29
+
30
+ ### The Engine (Backend)
31
+ * **Microservice Architecture:**
32
+ * **Books API:** Handles business logic, product data, and frontend communication.
33
+ * **Personalisation Engine:** A dedicated vector search service (FAISS + Transformers) deployed separately.
34
+ * **Performance:**
35
+ * **IVF-PQ Indexing:** 100k+ books indexed with 48x compression (150MB -> 3MB) for <50ms query times.
36
+ * **Hybrid Search:** Combines dense vector retrieval with metadata filtering.
37
+
38
+ ---
39
+
40
+ ## System Architecture
41
+
42
+ The system consists of two main services and a frontend:
43
+
44
+ ```mermaid
45
+ graph LR
46
+ User((User)) --> Frontend[React App]
47
+ Frontend --> BooksAPI[Books API (FastAPI)]
48
+ BooksAPI --> DB[(Book Catalog CSV)]
49
+ BooksAPI --> PersonaliseAPI[Personalisation Engine]
50
+ PersonaliseAPI --> VectorDB[(FAISS Index)]
51
+ ```
52
+
53
+ * **Frontend:** React, Tailwind, Vite (Static Build)
54
+ * **Books API:** Python, FastAPI (Deployed on Hugging Face Spaces)
55
+ * **Personalisation Engine:** Python, Sentence-Transformers, FAISS (Deployed on Hugging Face Spaces)
56
+
57
+ ---
58
+
59
+ ## Getting Started
60
+
61
+ ### Prerequisites
62
+ * **Python 3.10+** (Essential for dependency compatibility)
63
+ * **Node.js 18+**
64
+ * **Git**
65
+
66
+ ### 1. Clone the Repository
67
+ ```bash
68
+ git clone <your-repo-url>
69
+ cd books
70
+ ```
71
+
72
+ ### 2. Backend Setup (Books API)
73
+
74
+ It is highly recommended to use `uv` for fast dependency management, but `pip` works too.
75
+
76
+ ```bash
77
+ # 1. Create virtual environment
78
+ pip install uv
79
+ uv venv .venv
80
+
81
+ # 2. Activate environment
82
+ # Windows:
83
+ .venv\Scripts\activate
84
+ # Linux/Mac:
85
+ source .venv/bin/activate
86
+
87
+ # 3. Install dependencies
88
+ uv pip install -r requirements.txt
89
+ ```
90
+
91
+ **Configuration (`.env`):**
92
+ Copy `.env.example` to `.env` and configure the URL of your personalisation service.
93
+ ```bash
94
+ cp .env.example .env
95
+ ```
96
+ In `.env`:
97
+ ```ini
98
+ PORT=8000
99
+ # URL of your deployed Personalisation Engine (or http://localhost:8001 if running locally)
100
+ PERSONALIZER_URL=https://nice-bill-personalisation-engine.hf.space
101
+ ```
102
+
103
+ **Run the API:**
104
+ ```bash
105
+ python src/book_recommender/api/main.py
106
+ # API will start at http://localhost:8000
107
+ # Swagger Docs: http://localhost:8000/docs
108
+ ```
109
+
110
+ ### 3. Frontend Setup
111
+
112
+ ```bash
113
+ cd frontend
114
+
115
+ # 1. Install dependencies
116
+ npm install
117
+
118
+ # 2. Start Dev Server
119
+ npm run dev
120
+ ```
121
+ Open `http://localhost:5173` in your browser.
122
+
123
+ ---
124
+
125
+ ## Deployment Guide
126
+
127
+ ### Option 1: Docker (Recommended)
128
+
129
+ The project includes a production-ready `Dockerfile` optimized for Hugging Face Spaces.
130
+
131
+ ```bash
132
+ # Build the image
133
+ docker build -t serendipity-api -f docker/Dockerfile.backend .
134
+
135
+ # Run the container
136
+ docker run -p 7860:7860 -e PERSONALIZER_URL=https://nice-bill-personalisation-engine.hf.space serendipity-api
137
+ ```
138
+
139
+ ### Option 2: Hugging Face Spaces
140
+
141
+ 1. Create a new **Docker** Space on Hugging Face.
142
+ 2. Connect it to your GitHub repository (or push manually).
143
+ 3. Set the following **Variables** in the Space settings:
144
+ * `PERSONALIZER_URL`: `https://nice-bill-personalisation-engine.hf.space` (or your engine's URL)
145
+ 4. The Space will automatically build using `docker/Dockerfile.backend` and serve on port 7860.
146
+
147
+ ---
148
+
149
+ ## Project Structure
150
+
151
+ ```
152
+ books/
153
+ ├── data/ # Data storage
154
+ │ ├── catalog/ # CSV/Parquet metadata
155
+ │ └── embeddings_cache.npy # Vector data
156
+ ├── docker/ # Docker configuration
157
+ │ └── Dockerfile.backend
158
+ ├── frontend/ # React Application
159
+ │ ├── src/
160
+ │ └���─ public/
161
+ ├── scripts/ # Data processing scripts
162
+ │ ├── download_data.py # Fetches datasets
163
+ │ └── prepare_100k_data.py # Raw data cleaning
164
+ ├── src/
165
+ │ └── book_recommender/
166
+ │ ├── api/ # FastAPI endpoints
167
+ │ ├── ml/ # Machine Learning logic
168
+ │ └── services/ # External service connectors
169
+ └── tests/ # Pytest suite
170
+ ```
171
+
172
+ ## License
173
+ MIT License.
data/feedback/user_feedback.jsonl ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {"timestamp": "2025-12-02T20:22:43.042683", "query": "fantasy adventure", "book_id": "4", "book_title": "Unrelated Book", "book_authors": "Author C", "feedback": "positive", "session_id": "test_session_1"}
2
+ {"timestamp": "2025-12-02T20:22:43.057774", "query": "sci-fi classic", "book_id": "5", "book_title": "Sci-Fi Classic", "book_authors": "Author D", "feedback": "negative", "session_id": "test_session_1"}
3
+ {"timestamp": "2025-12-02T21:43:22.362305", "query": "Cyberpunk noir detective", "book_id": "70962", "book_title": "Murder in the Rue de Paradis", "book_authors": "Cara Black", "feedback": "positive", "session_id": null}
4
+ {"timestamp": "2025-12-03T00:35:54.674627", "query": "Psychological horror 1920s", "book_id": "89722", "book_title": "Three Case Histories", "book_authors": "Sigmund Freud,Philip Rieff", "feedback": "positive", "session_id": null}
docs/ARCHITECTURE.md ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture Overview: DeepShelf
2
+
3
+ This document provides a detailed overview of the BookFinder application's architecture, outlining its components, data flow, and key technology decisions.
4
+
5
+ ## System Overview
6
+
7
+ BookFinder is a content-based book recommendation system that leverages natural language processing (NLP) and vector similarity search to help users discover books. It comprises a Streamlit-based web application for user interaction, a FastAPI service for programmatic access, and a suite of Python scripts for data processing and embedding generation.
8
+
9
+ The core principle is to transform book descriptions into high-dimensional numerical vectors (embeddings) using a pre-trained sentence transformer model. These embeddings are then used to find semantically similar books or to cluster books into thematic collections.
10
+
11
+ ## Component Descriptions
12
+
13
+ The system is structured into several modular components:
14
+
15
+ 1. **Data Layer (`data/`)**: Stores raw, prepared, and processed data.
16
+ * `data/raw/`: Original datasets (e.g., `goodreads_data.csv`).
17
+ * `data/processed/`: Cleaned book metadata (`books_cleaned.parquet`), pre-computed embeddings (`book_embeddings.npy`), and embedding metadata (`embedding_metadata.json`).
18
+
19
+ 2. **Scripts Layer (`scripts/`)**: Utility scripts for data handling and model preparation.
20
+ * `prepare_goodreads_data.py`: Adapts raw data sources to a standardized format.
21
+ * `download_model.py` (optional): For pre-downloading the sentence transformer model.
22
+
23
+ 3. **Source Code Layer (`src/book_recommender/`)**: Contains the core logic of the application, organized into sub-packages.
24
+ * **`src/book_recommender/core/`**: Core utilities and configurations.
25
+ * `config.py`: Centralized configuration settings and constants.
26
+ * `exceptions.py`: Custom exception definitions for error handling.
27
+ * `logging_config.py`: Centralized logging configuration for the entire application.
28
+ * **`src/book_recommender/data/`**: Data processing components.
29
+ * `processor.py`: Handles data cleaning, normalization, deduplication, and feature engineering (e.g., creating `combined_text` for embeddings).
30
+ * **`src/book_recommender/ml/`**: Machine Learning related components.
31
+ * `embedder.py`: Manages the loading of the Sentence Transformer model and generation of embeddings for books and user queries. It uses `lru_cache` for efficient model loading.
32
+ * `recommender.py`: Implements the core recommendation logic using a FAISS index for fast Approximate Nearest Neighbor (ANN) search on book embeddings.
33
+ * `clustering.py`: Contains logic for K-Means clustering of book embeddings and generating descriptive names for these clusters based on common genres.
34
+ * `explainability.py`: Provides rule-based explanations for recommendations, detailing contributing factors like genre, keywords, and author similarity.
35
+ * `feedback.py`: Manages saving and retrieving user feedback on recommendations to a JSONL file.
36
+ * `src/book_recommender/utils.py`: General utility functions, including book cover fetching from various APIs (Google Books, Open Library) and batch processing.
37
+
38
+ 4. **User Interface Layer (`src/book_recommender/apps/`)**: Streamlit applications.
39
+ * `main_app.py`: The main interactive web application where users can get book recommendations, browse by genre/query, view explanations, and provide feedback.
40
+ * `analytics_app.py`: A separate Streamlit dashboard to visualize collected user feedback and system usage statistics.
41
+
42
+ 5. **API Layer (`src/book_recommender/api/`)**: FastAPI application for programmatic access.
43
+ * `main.py`: Defines the FastAPI application and its endpoints (recommendations, book listing, search, stats, clusters, explanations, feedback).
44
+ * `models.py`: Pydantic models for request and response data validation and serialization.
45
+ * `dependencies.py`: FastAPI dependency injection functions to manage and cache shared resources like the `BookRecommender` and embedding models.
46
+
47
+ 6. **CI/CD (`.github/workflows/ci-cd.yml`)**: GitHub Actions workflow for automated testing and code quality checks.
48
+
49
+ 7. **Containerization (`streamlit.Dockerfile`, `api.Dockerfile`, `analytics.Dockerfile`, `docker-compose.yml`)**: For packaging and orchestrating the application services.
50
+ * `streamlit.Dockerfile`: Defines the build process for the main Streamlit application into a Docker image.
51
+ * `api.Dockerfile`: Defines the build process for the FastAPI application into a Docker image.
52
+ * `analytics.Dockerfile`: Defines the build process for the analytics Streamlit application into a Docker image.
53
+ * `docker-compose.yml`: Orchestrates the `streamlit`, `api`, and `analytics` services for local development and deployment.
54
+
55
+ ## Data Flow
56
+
57
+ The primary data flow for generating recommendations and user interaction is as follows:
58
+
59
+ 1. **Raw Data Ingestion**: Raw CSV datasets (e.g., `goodreads_data.csv`) are placed in `data/raw/`.
60
+ 2. **Data Preparation**: The `scripts/prepare_goodreads_data.py` script and `src/book_recommender/data/processor.py` clean, standardize, and deduplicate the raw data, saving it as `books_cleaned.parquet` in `data/processed/`.
61
+ 3. **Embedding Generation**: `src/book_recommender/ml/embedder.py` loads `books_cleaned.parquet` and generates semantic embeddings for each book's `combined_text`, saving them as `book_embeddings.npy` in `data/processed/`.
62
+ 4. **Application Startup**:
63
+ * The `src/book_recommender/apps/main_app.py` (Streamlit) and `src/book_recommender/api/main.py` (FastAPI) applications load `books_cleaned.parquet` and `book_embeddings.npy` into memory.
64
+ * `src/book_recommender/ml/recommender.py` initializes a FAISS index with these embeddings for fast similarity search.
65
+ * `src/book_recommender/ml/clustering.py` generates book clusters and their names from the embeddings.
66
+ 5. **User Interaction (Streamlit `src/book_recommender/apps/main_app.py`)**:
67
+ * User inputs a natural language query or browses clusters.
68
+ * If a query, `src/book_recommender/ml/embedder.py` generates an embedding for the query.
69
+ * `src/book_recommender/ml/recommender.py` uses the query embedding (or a book's embedding from a title search) to find similar books.
70
+ * `src/book_recommender/ml/explainability.py` generates reasons for recommendations.
71
+ * `src/book_recommender/utils.py` fetches book cover images.
72
+ * `src/book_recommender/ml/feedback.py` stores user feedback on recommendations.
73
+ 6. **API Interaction (FastAPI `src/book_recommender/api/main.py`)**:
74
+ * External clients send requests to API endpoints (e.g., `/recommend/query`, `/books`, `/feedback`).
75
+ * `src/book_recommender/api/dependencies.py` ensures efficient loading and caching of `recommender` and `embedder` instances.
76
+ * Requests are validated using Pydantic models (`src/book_recommender/api/models.py`).
77
+ * Core logic in `src/book_recommender/` modules is invoked (e.g., `recommender.py`, `explainability.py`, `feedback.py`).
78
+ * Responses are returned, adhering to defined Pydantic response models.
79
+
80
+ **Example API Calls:**
81
+
82
+ ```bash
83
+ # Health Check
84
+ curl http://localhost:8000/health
85
+
86
+ # Recommend by Query
87
+ curl -X POST "http://localhost:8000/recommend/query" \
88
+ -H "Content-Type: application/json" \
89
+ -d '{
90
+ "query": "fantasy adventure with dragons",
91
+ "top_k": 5
92
+ }'
93
+
94
+ # List all Clusters
95
+ curl http://localhost:8000/clusters
96
+
97
+ # Submit Feedback
98
+ curl -X POST "http://localhost:8000/feedback" \
99
+ -H "Content-Type: application/json" \
100
+ -d '{
101
+ "query": "fantasy adventure with dragons",
102
+ "book_id": "example_book_id",
103
+ "feedback_type": "positive",
104
+ "session_id": "user_session_abc"
105
+ }'
106
+ ```
107
+
108
+ 7. **Analytics (`src/book_recommender/apps/analytics_app.py`)**:
109
+ * Loads accumulated feedback data from `data/feedback/user_feedback.jsonl` using `src/book_recommender/ml/feedback.py`.
110
+ * Processes and visualizes statistics using Streamlit and Plotly.
111
+
112
+ ```mermaid
113
+ graph TD
114
+ subgraph Data Flow & Processing
115
+ raw_data[Raw Data (CSV)] --> A(scripts/prepare_goodreads_data.py);
116
+ A --> B[books_prepared.csv];
117
+ B --> C{src/book_recommender/data/processor.py};
118
+ C --> D[books_cleaned.parquet];
119
+ D --contains text--> E{src/book_recommender/ml/embedder.py};
120
+ E --> F[book_embeddings.npy];
121
+ end
122
+
123
+ subgraph Application Runtime
124
+ G(src/book_recommender/apps/main_app.py - Streamlit UI) --loads--> D & F;
125
+ G --uses--> H(src/book_recommender/ml/recommender.py);
126
+ G --uses--> I(src/book_recommender/ml/embedder.py);
127
+ G --uses--> J(src/book_recommender/ml/clustering.py);
128
+ G --uses--> K(src/book_recommender/ml/explainability.py);
129
+ G --uses--> L(src/book_recommender/ml/feedback.py);
130
+
131
+ M(src/book_recommender/api/main.py - FastAPI) --uses--> N(src/book_recommender/api/dependencies.py);
132
+ N --loads--> D & F;
133
+ N --uses--> H & I & J & K & L;
134
+
135
+ O(src/book_recommender/apps/analytics_app.py - Streamlit Dashboard) --loads--> P[user_feedback.jsonl];
136
+ P --via--> L;
137
+
138
+ User[User] --Interacts with--> G;
139
+ Client[External Client] --Interacts with--> M;
140
+ end
141
+
142
+ subgraph Utilities & Configuration
143
+ Q[src/book_recommender/core/config.py];
144
+ R[src/book_recommender/utils.py];
145
+ S[src/book_recommender/core/logging_config.py];
146
+ end
147
+
148
+ style raw_data fill:#f9f,stroke:#333,stroke-width:2px
149
+ style D fill:#ccf,stroke:#333,stroke-width:2px
150
+ style F fill:#ccf,stroke:#333,stroke-width:2px
151
+ style P fill:#fcc,stroke:#333,stroke-width:2px
152
+ ```
153
+
154
+ ## Technology Decisions
155
+
156
+ * **Python 3.10+**: Modern, versatile language.
157
+ * **Streamlit**: Chosen for rapid development of interactive web UIs with minimal frontend code. Its caching mechanisms (`@st.cache_resource`, `@st.cache_data`) are crucial for performance with ML models.
158
+ * **FastAPI**: Selected for building a high-performance, asynchronous API with automatic Pydantic-based data validation and Swagger/OpenAPI documentation.
159
+ * **Sentence-Transformers**: A powerful library for generating dense vector embeddings from text, suitable for semantic search.
160
+ * **FAISS**: An efficient library for similarity search and clustering of dense vectors, essential for scaling recommendations.
161
+ * **Pandas / NumPy**: Standard libraries for data manipulation and numerical operations.
162
+ * **Scikit-learn**: Used for traditional machine learning tasks like K-Means clustering.
163
+ * **Plotly Express**: For creating interactive and aesthetically pleasing visualizations in the analytics dashboard.
164
+ * **Pydantic**: Data validation and settings management using Python type hints, integral to FastAPI.
165
+ * **python-dotenv**: For managing environment variables, facilitating flexible configuration across environments.
166
+ * **GitHub Actions**: For Continuous Integration/Continuous Deployment (CI/CD), automating testing, linting, and Docker image builds.
167
+ * **Docker / Docker Compose**: For containerizing the application and orchestrating multi-service deployments, ensuring consistent environments.
168
+
169
+ ## Future Considerations
170
+
171
+ * **Data Version Control (DVC)**: Implement DVC for robust tracking of data and model versions, enhancing reproducibility in production.
172
+ * **Scalability**: For extremely large datasets, consider distributed FAISS indexes or cloud-native vector databases.
173
+ * **Advanced Explanations**: Explore more sophisticated XAI techniques beyond rule-based, potentially involving LLMs or specific feature attribution methods.
174
+ * **User Authentication**: For multi-user scenarios, integrate an authentication system (e.g., OAuth2, JWT).
175
+ * **Database Integration**: Replace JSONL feedback storage with a dedicated database (e.g., PostgreSQL) for more robust data management and querying.
176
+ * **Full UI Testing**: Implement UI tests using tools like Playwright or Selenium to ensure frontend consistency and functionality.
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
17
+
18
+ ```js
19
+ export default defineConfig([
20
+ globalIgnores(['dist']),
21
+ {
22
+ files: ['**/*.{ts,tsx}'],
23
+ extends: [
24
+ // Other configs...
25
+
26
+ // Remove tseslint.configs.recommended and replace with this
27
+ tseslint.configs.recommendedTypeChecked,
28
+ // Alternatively, use this for stricter rules
29
+ tseslint.configs.strictTypeChecked,
30
+ // Optionally, add this for stylistic rules
31
+ tseslint.configs.stylisticTypeChecked,
32
+
33
+ // Other configs...
34
+ ],
35
+ languageOptions: {
36
+ parserOptions: {
37
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
38
+ tsconfigRootDir: import.meta.dirname,
39
+ },
40
+ // other options...
41
+ },
42
+ },
43
+ ])
44
+ ```
45
+
46
+ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
47
+
48
+ ```js
49
+ // eslint.config.js
50
+ import reactX from 'eslint-plugin-react-x'
51
+ import reactDom from 'eslint-plugin-react-dom'
52
+
53
+ export default defineConfig([
54
+ globalIgnores(['dist']),
55
+ {
56
+ files: ['**/*.{ts,tsx}'],
57
+ extends: [
58
+ // Other configs...
59
+ // Enable lint rules for React
60
+ reactX.configs['recommended-typescript'],
61
+ // Enable lint rules for React DOM
62
+ reactDom.configs.recommended,
63
+ ],
64
+ languageOptions: {
65
+ parserOptions: {
66
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
67
+ tsconfigRootDir: import.meta.dirname,
68
+ },
69
+ // other options...
70
+ },
71
+ },
72
+ ])
73
+ ```
frontend/eslint.config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { defineConfig, globalIgnores } from 'eslint/config'
7
+
8
+ export default defineConfig([
9
+ globalIgnores(['dist']),
10
+ {
11
+ files: ['**/*.{ts,tsx}'],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs.flat.recommended,
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ])
frontend/index.html ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400..800;1,400..800&family=Inter:wght@100..900&display=swap" rel="stylesheet">
10
+ <title>Serendipity</title>
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/main.tsx"></script>
15
+ </body>
16
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "serendipity-web",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "axios": "^1.13.2",
14
+ "clsx": "^2.1.1",
15
+ "lucide-react": "^0.555.0",
16
+ "react": "^19.2.0",
17
+ "react-dom": "^19.2.0",
18
+ "react-router-dom": "^7.9.6",
19
+ "tailwind-merge": "^3.4.0"
20
+ },
21
+ "devDependencies": {
22
+ "@eslint/js": "^9.39.1",
23
+ "@types/node": "^24.10.1",
24
+ "@types/react": "^19.2.5",
25
+ "@types/react-dom": "^19.2.3",
26
+ "@vitejs/plugin-react": "^5.1.1",
27
+ "autoprefixer": "^10.4.22",
28
+ "eslint": "^9.39.1",
29
+ "eslint-plugin-react-hooks": "^7.0.1",
30
+ "eslint-plugin-react-refresh": "^0.4.24",
31
+ "globals": "^16.5.0",
32
+ "postcss": "^8.5.6",
33
+ "tailwindcss": "^3.4.17",
34
+ "typescript": "~5.9.3",
35
+ "typescript-eslint": "^8.46.4",
36
+ "vite": "^7.2.4"
37
+ }
38
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
frontend/public/vite.svg ADDED
frontend/src/App.css ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #root {
2
+ max-width: 1280px;
3
+ margin: 0 auto;
4
+ padding: 2rem;
5
+ text-align: center;
6
+ }
7
+
8
+ .logo {
9
+ height: 6em;
10
+ padding: 1.5em;
11
+ will-change: filter;
12
+ transition: filter 300ms;
13
+ }
14
+ .logo:hover {
15
+ filter: drop-shadow(0 0 2em #646cffaa);
16
+ }
17
+ .logo.react:hover {
18
+ filter: drop-shadow(0 0 2em #61dafbaa);
19
+ }
20
+
21
+ @keyframes logo-spin {
22
+ from {
23
+ transform: rotate(0deg);
24
+ }
25
+ to {
26
+ transform: rotate(360deg);
27
+ }
28
+ }
29
+
30
+ @media (prefers-reduced-motion: no-preference) {
31
+ a:nth-of-type(2) .logo {
32
+ animation: logo-spin infinite 20s linear;
33
+ }
34
+ }
35
+
36
+ .card {
37
+ padding: 2em;
38
+ }
39
+
40
+ .read-the-docs {
41
+ color: #888;
42
+ }
frontend/src/App.tsx ADDED
@@ -0,0 +1,616 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import {
3
+ Search, BookOpen, Loader2, X, Sparkles, LayoutGrid,
4
+ CheckCircle, Moon, Sun, Info, Bookmark, ArrowLeft, Users
5
+ } from 'lucide-react';
6
+ import { api } from './api';
7
+ import { Loader } from './Loader';
8
+ import { BookCover } from './BookCover';
9
+ import { BookCard } from './BookCard';
10
+ import { DEMO_PERSONAS } from './data/personas';
11
+ import type { RecommendationResult, BookCluster } from './types';
12
+
13
+ function App() {
14
+ const [query, setQuery] = useState('');
15
+ const [results, setResults] = useState<RecommendationResult[]>([]);
16
+ const [loading, setLoading] = useState(false);
17
+ const [longLoading, setLongLoading] = useState(false);
18
+ const [hasSearched, setHasSearched] = useState(false);
19
+
20
+ // Modal State
21
+ const [selectedResult, setSelectedResult] = useState<RecommendationResult | null>(null);
22
+ const [historyStack, setHistoryStack] = useState<RecommendationResult[]>([]);
23
+ const [explanation, setExplanation] = useState<{ summary: string; details: Record<string, number> } | null>(null);
24
+ const [explaining, setExplaining] = useState(false);
25
+ const [showAbout, setShowAbout] = useState(false);
26
+ const modalRef = useRef<HTMLDivElement>(null);
27
+
28
+ // Related Books State
29
+ const [relatedBooks, setRelatedBooks] = useState<RecommendationResult[]>([]);
30
+ const [loadingRelated, setLoadingRelated] = useState(false);
31
+
32
+ // Dynamic Clusters State
33
+ const [clusters, setClusters] = useState<BookCluster[]>([]);
34
+ const [loadingClusters, setLoadingClusters] = useState(true);
35
+
36
+ // Toast State
37
+ const [toast, setToast] = useState<{ message: string; visible: boolean } | null>(null);
38
+
39
+ // Settings / Theme State
40
+ const [darkMode, setDarkMode] = useState(false);
41
+
42
+ // Personalization / History State
43
+ const [readHistory, setReadHistory] = useState<string[]>(() => {
44
+ try {
45
+ return JSON.parse(localStorage.getItem('bookfinder_read_history') || '[]');
46
+ } catch {
47
+ return [];
48
+ }
49
+ });
50
+
51
+ useEffect(() => {
52
+ if (selectedResult && modalRef.current) {
53
+ modalRef.current.scrollTop = 0;
54
+ }
55
+ }, [selectedResult]);
56
+
57
+ useEffect(() => {
58
+ const savedTheme = localStorage.getItem('bookfinder_theme_mode');
59
+ if (savedTheme) {
60
+ setDarkMode(savedTheme === 'dark');
61
+ } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
62
+ setDarkMode(true);
63
+ }
64
+ }, []);
65
+
66
+ useEffect(() => {
67
+ localStorage.setItem('bookfinder_theme_mode', darkMode ? 'dark' : 'light');
68
+ if (darkMode) {
69
+ document.documentElement.classList.add('dark');
70
+ } else {
71
+ document.documentElement.classList.remove('dark');
72
+ }
73
+ }, [darkMode]);
74
+
75
+ useEffect(() => {
76
+ localStorage.setItem('bookfinder_read_history', JSON.stringify(readHistory));
77
+ }, [readHistory]);
78
+
79
+ const toggleReadBook = (title: string) => {
80
+ setReadHistory(prev => {
81
+ if (prev.includes(title)) {
82
+ showToast("Removed from library");
83
+ return prev.filter(t => t !== title);
84
+ } else {
85
+ showToast("Added to library");
86
+ return [...prev, title];
87
+ }
88
+ });
89
+ triggerHaptic();
90
+ };
91
+
92
+ const handlePersonalize = async () => {
93
+ if (readHistory.length === 0) {
94
+ showToast("Mark some books as read first!");
95
+ return;
96
+ }
97
+ triggerHaptic();
98
+ setLoading(true);
99
+ setHasSearched(true);
100
+ setResults([]);
101
+ setQuery("✨ Selected for you");
102
+
103
+ try {
104
+ const data = await api.recommendPersonalized(readHistory);
105
+ setResults(data);
106
+ } catch (err) {
107
+ console.error(err);
108
+ showToast("Failed to personalize. Try again.");
109
+ } finally {
110
+ setLoading(false);
111
+ }
112
+ };
113
+
114
+ const handlePersonaSelect = (persona: typeof DEMO_PERSONAS[0]) => {
115
+ triggerHaptic();
116
+ setReadHistory(persona.history);
117
+ showToast(`Switched to ${persona.name} mode`);
118
+
119
+ setLoading(true);
120
+ setHasSearched(true);
121
+ setResults([]);
122
+ setQuery(`👤 Demo: ${persona.name}`);
123
+
124
+ api.recommendPersonalized(persona.history)
125
+ .then(setResults)
126
+ .catch(err => {
127
+ console.error(err);
128
+ showToast("Failed to load persona.");
129
+ })
130
+ .finally(() => setLoading(false));
131
+ };
132
+
133
+ const toggleTheme = async (e: React.MouseEvent) => {
134
+ const isDark = !darkMode;
135
+ if (!(document as any).startViewTransition) {
136
+ setDarkMode(isDark);
137
+ return;
138
+ }
139
+ const x = e.clientX;
140
+ const y = e.clientY;
141
+ const endRadius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y));
142
+ const transition = (document as any).startViewTransition(() => setDarkMode(isDark));
143
+ await transition.ready;
144
+ const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`];
145
+ document.documentElement.animate(
146
+ { clipPath: isDark ? clipPath : [...clipPath].reverse() },
147
+ { duration: 500, easing: 'ease-in-out', pseudoElement: isDark ? '::view-transition-new(root)' : '::view-transition-old(root)' }
148
+ );
149
+ };
150
+
151
+ const triggerHaptic = () => {
152
+ if (typeof navigator !== 'undefined' && navigator.vibrate) {
153
+ navigator.vibrate(10);
154
+ }
155
+ };
156
+
157
+ useEffect(() => {
158
+ const fetchClusters = async () => {
159
+ try {
160
+ const data = await api.getClusters();
161
+ setClusters(data.slice(0, 6));
162
+ } catch (err) {
163
+ console.error("Failed to fetch clusters", err);
164
+ } finally {
165
+ setLoadingClusters(false);
166
+ }
167
+ };
168
+ fetchClusters();
169
+ }, []);
170
+
171
+ const showToast = (message: string) => {
172
+ setToast({ message, visible: true });
173
+ setTimeout(() => setToast(null), 3000);
174
+ };
175
+
176
+ const handleSearch = async (e?: React.FormEvent) => {
177
+ if (e) e.preventDefault();
178
+ if (!query.trim()) return;
179
+ triggerHaptic();
180
+ setLoading(true);
181
+ setLongLoading(false);
182
+ setHasSearched(true);
183
+ setResults([]);
184
+ const timer = setTimeout(() => setLongLoading(true), 3000);
185
+ try {
186
+ const data = await api.recommendByQuery(query);
187
+ setResults(data);
188
+ } catch (err) {
189
+ console.error(err);
190
+ } finally {
191
+ clearTimeout(timer);
192
+ setLoading(false);
193
+ setLongLoading(false);
194
+ }
195
+ };
196
+
197
+ const handleQuickSearch = (text: string) => {
198
+ setQuery(text);
199
+ triggerHaptic();
200
+ setLoading(true);
201
+ setLongLoading(false);
202
+ setHasSearched(true);
203
+ setResults([]);
204
+ const timer = setTimeout(() => setLongLoading(true), 3000);
205
+ api.recommendByQuery(text).then(setResults).catch(console.error).finally(() => {
206
+ clearTimeout(timer);
207
+ setLoading(false);
208
+ setLongLoading(false);
209
+ });
210
+ };
211
+
212
+ const handleFeedback = async (bookId: string, type: 'positive' | 'negative') => {
213
+ triggerHaptic();
214
+ try {
215
+ await api.submitFeedback(query, bookId, type);
216
+ showToast(type === 'positive' ? "Thanks! We'll show more like this." : "Thanks! We'll tune our results.");
217
+ } catch (err) {
218
+ console.error(err);
219
+ }
220
+ };
221
+
222
+ const handleReadMore = async (result: RecommendationResult, isDrillingDown = false) => {
223
+ triggerHaptic();
224
+ if (isDrillingDown && selectedResult) {
225
+ setHistoryStack(prev => [...prev, selectedResult]);
226
+ } else if (!isDrillingDown) {
227
+ setHistoryStack([]);
228
+ }
229
+ setSelectedResult(result);
230
+ setExplaining(true);
231
+ setExplanation(null);
232
+ setRelatedBooks([]);
233
+ setLoadingRelated(true);
234
+ try {
235
+ const [expl, related] = await Promise.allSettled([
236
+ api.explainRecommendation(query || result.book.title, result.book, result.similarity_score),
237
+ api.getRelatedBooks(result.book.title)
238
+ ]);
239
+ if (expl.status === 'fulfilled') setExplanation(expl.value);
240
+ if (related.status === 'fulfilled') setRelatedBooks(related.value);
241
+ } catch (err) {
242
+ console.error("Failed to fetch details", err);
243
+ } finally {
244
+ setExplaining(false);
245
+ setLoadingRelated(false);
246
+ }
247
+ };
248
+
249
+ const handleBack = () => {
250
+ triggerHaptic();
251
+ if (historyStack.length > 0) {
252
+ const prev = historyStack[historyStack.length - 1];
253
+ setHistoryStack(curr => curr.slice(0, -1));
254
+ setSelectedResult(prev);
255
+ setRelatedBooks([]);
256
+ setExplanation(null);
257
+ setLoadingRelated(true);
258
+ setExplaining(true);
259
+ Promise.allSettled([
260
+ api.explainRecommendation(query || prev.book.title, prev.book, prev.similarity_score),
261
+ api.getRelatedBooks(prev.book.title)
262
+ ]).then(([expl, related]) => {
263
+ if (expl.status === 'fulfilled') setExplanation(expl.value);
264
+ if (related.status === 'fulfilled') setRelatedBooks(related.value);
265
+ setExplaining(false);
266
+ setLoadingRelated(false);
267
+ });
268
+ }
269
+ };
270
+
271
+ const closeModal = () => {
272
+ setSelectedResult(null);
273
+ setHistoryStack([]);
274
+ setExplanation(null);
275
+ setRelatedBooks([]);
276
+ };
277
+
278
+ const handleClusterClick = (clusterName: string) => {
279
+ triggerHaptic();
280
+ setQuery(clusterName);
281
+ setLoading(true);
282
+ setHasSearched(true);
283
+ setResults([]);
284
+ api.recommendByQuery(clusterName).then(setResults).catch(console.error).finally(() => setLoading(false));
285
+ };
286
+
287
+ return (
288
+ <div className={`min-h-screen font-sans selection:bg-indigo-500 selection:text-white transition-colors duration-300 ${darkMode ? 'bg-zinc-950 text-zinc-100' : 'bg-zinc-50 text-zinc-900'}`}>
289
+
290
+ <div className="fixed inset-0 -z-10 bg-zinc-50 dark:bg-zinc-950 transition-colors duration-500" />
291
+ <div className="fixed inset-0 -z-10 bg-noise opacity-[0.04] dark:opacity-[0.06] pointer-events-none mix-blend-overlay" />
292
+ <div className="fixed inset-0 -z-10 bg-dot-pattern pointer-events-none" />
293
+ <div className="fixed inset-0 -z-10 bg-gradient-to-br from-rose-100/40 via-white to-indigo-100/40 dark:from-indigo-950/30 dark:via-zinc-950 dark:to-purple-950/30 pointer-events-none" />
294
+
295
+ {toast && (
296
+ <div className="fixed bottom-6 right-6 bg-zinc-900 dark:bg-zinc-800 text-white px-4 py-3 rounded-xl shadow-2xl flex items-center gap-3 animate-slide-up z-[60] border border-zinc-800 dark:border-zinc-700">
297
+ <CheckCircle className="w-5 h-5 text-green-400" />
298
+ <span className="text-sm font-medium">{toast.message}</span>
299
+ </div>
300
+ )}
301
+
302
+ <header className="sticky top-0 z-30 border-b border-zinc-200/50 dark:border-zinc-800/50 bg-white/70 dark:bg-zinc-950/70 backdrop-blur-xl">
303
+ <div className="max-w-3xl mx-auto px-6 h-16 flex items-center justify-between">
304
+ <div className="flex items-center gap-3 group cursor-pointer" onClick={() => setActiveView('search')}>
305
+ <div className="relative">
306
+ <div className="absolute -inset-1 bg-indigo-500/20 rounded-full blur-sm group-hover:bg-indigo-500/30 transition-colors"></div>
307
+ <BookOpen className="w-8 h-8 text-indigo-600 dark:text-indigo-400 relative" />
308
+ </div>
309
+ <span className="text-zinc-900 dark:text-white font-serif text-xl tracking-normal">Serendipity</span>
310
+ </div>
311
+
312
+ <div className="flex items-center gap-2">
313
+ <button onClick={toggleTheme} className="p-2 rounded-full hover:bg-black/5 dark:hover:bg-white/10 transition-colors text-zinc-500 dark:text-zinc-400">
314
+ {darkMode ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
315
+ </button>
316
+ <button onClick={() => setShowAbout(true)} className="p-2 ml-2 rounded-full hover:bg-black/5 dark:hover:bg-white/10 transition-colors text-zinc-500 dark:text-zinc-400">
317
+ <Info className="w-5 h-5" />
318
+ </button>
319
+ </div>
320
+ </div>
321
+ </header>
322
+
323
+ <main className="max-w-3xl mx-auto px-6 pb-24 relative z-10">
324
+ <div className={`transition-all duration-700 ease-out ${hasSearched ? 'py-10' : 'py-28'}`}>
325
+ {!hasSearched && (
326
+ <div className="text-center mb-10 animate-fade-in">
327
+ <h1 className="text-5xl sm:text-7xl font-black tracking-tighter mb-6 text-zinc-900 dark:text-white font-serif">
328
+ Find your next<br/>
329
+ <span className="text-indigo-600 dark:text-indigo-500 italic">obsession.</span>
330
+ </h1>
331
+ <p className="text-lg max-w-md mx-auto text-zinc-500 dark:text-zinc-400 leading-relaxed font-medium">
332
+ Describe the vibe, plot, or character you're looking for.
333
+ </p>
334
+ </div>
335
+ )}
336
+
337
+ <form onSubmit={handleSearch} className="relative max-w-xl mx-auto z-10">
338
+ <input
339
+ type="text"
340
+ value={query}
341
+ onChange={(e) => setQuery(e.target.value)}
342
+ placeholder="e.g. A sci-fi thriller about AI consciousness..."
343
+ className="relative w-full bg-white dark:bg-zinc-900 border-2 border-zinc-200 dark:border-zinc-800 focus:border-indigo-500 dark:focus:border-indigo-500 rounded-full py-4 pl-6 pr-24 text-lg outline-none transition-all placeholder:text-zinc-400 dark:placeholder:text-zinc-600 shadow-xl shadow-zinc-200/50 dark:shadow-black/50 text-zinc-900 dark:text-white"
344
+ autoFocus
345
+ />
346
+ {query && (
347
+ <button
348
+ type="button"
349
+ onClick={() => { setQuery(''); triggerHaptic(); setResults([]); }}
350
+ className="absolute right-14 top-1/2 -translate-y-1/2 p-2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors"
351
+ >
352
+ <X className="w-5 h-5" />
353
+ </button>
354
+ )}
355
+ <button
356
+ type="submit"
357
+ disabled={loading || !query.trim()}
358
+ className="absolute right-2 top-2 p-2.5 bg-indigo-600 text-white rounded-full hover:bg-indigo-700 disabled:opacity-50 disabled:hover:bg-indigo-600 transition-all active:scale-90 shadow-lg shadow-indigo-500/30"
359
+ onClick={triggerHaptic}
360
+ >
361
+ {loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <Search className="w-5 h-5" />}
362
+ </button>
363
+ </form>
364
+
365
+ {loading && longLoading && (
366
+ <div className="text-center mt-4 animate-fade-in">
367
+ <p className="text-sm font-medium text-indigo-600 dark:text-indigo-400 flex items-center justify-center gap-2">
368
+ <Loader2 className="w-3 h-3 animate-spin" />
369
+ Waking up... this might take a moment 😴
370
+ </p>
371
+ </div>
372
+ )}
373
+
374
+ {!hasSearched && !loading && (
375
+ <div className="mt-8 flex flex-col items-center gap-6 animate-fade-in animation-delay-200">
376
+ <div className="flex flex-wrap justify-center gap-3">
377
+ {[
378
+ "Cyberpunk noir detective",
379
+ "Cozy cottagecore mystery",
380
+ "Space opera with politics",
381
+ "Psychological horror 1920s"
382
+ ].map((prompt) => (
383
+ <button key={prompt} onClick={() => handleQuickSearch(prompt)} className="px-4 py-2 rounded-full text-sm font-medium bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400 hover:border-indigo-400 dark:hover:border-indigo-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-all shadow-sm hover:shadow-md active:scale-95">
384
+ ✨ {prompt}
385
+ </button>
386
+ ))}
387
+ </div>
388
+
389
+ {/* Demo Personas */}
390
+ <div className="w-full max-w-2xl mt-4 p-6 bg-zinc-50/50 dark:bg-zinc-900/30 rounded-3xl border border-zinc-100 dark:border-zinc-800/50 backdrop-blur-sm">
391
+ <div className="flex items-center justify-center gap-2 text-xs uppercase tracking-widest font-bold mb-4 text-zinc-400 dark:text-zinc-600">
392
+ <Users className="w-4 h-4" />
393
+ Try a Demo Persona
394
+ </div>
395
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
396
+ {DEMO_PERSONAS.map(persona => (
397
+ <button
398
+ key={persona.id}
399
+ onClick={() => handlePersonaSelect(persona)}
400
+ className="flex flex-col items-center gap-2 p-3 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 hover:border-indigo-400 dark:hover:border-indigo-500 hover:shadow-lg transition-all active:scale-95 group"
401
+ >
402
+ <span className="text-2xl group-hover:scale-110 transition-transform">{persona.emoji}</span>
403
+ <div className="text-center">
404
+ <div className="text-xs font-bold text-zinc-900 dark:text-zinc-100">{persona.name}</div>
405
+ <div className="text-[10px] text-zinc-500 dark:text-zinc-500 leading-tight mt-1 line-clamp-2">{persona.description}</div>
406
+ </div>
407
+ </button>
408
+ ))}
409
+ </div>
410
+ </div>
411
+
412
+ {/* Personalization Trigger */}
413
+ {readHistory.length > 0 && (
414
+ <button
415
+ onClick={handlePersonalize}
416
+ className="mt-2 flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-full font-bold text-sm shadow-xl shadow-indigo-500/20 hover:scale-105 active:scale-95 transition-all"
417
+ >
418
+ <Bookmark className="w-4 h-4 fill-white/20" />
419
+ Recommend based on my {readHistory.length} reads
420
+ </button>
421
+ )}
422
+ </div>
423
+ )}
424
+
425
+ {!hasSearched && (
426
+ <div className="mt-20 animate-slide-up animation-delay-500">
427
+ <div className="flex items-center justify-center gap-2 text-xs uppercase tracking-widest font-bold mb-8 text-zinc-400 dark:text-zinc-600">
428
+ <LayoutGrid className="w-4 h-4" />
429
+ Curated Collections
430
+ </div>
431
+
432
+ {loadingClusters ? (
433
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
434
+ {[1,2,3,4,5,6].map(i => (
435
+ <div key={i} className="h-32 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 animate-pulse" />
436
+ ))}
437
+ </div>
438
+ ) : (
439
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
440
+ {clusters.map(cluster => (
441
+ <button
442
+ key={cluster.id}
443
+ onClick={() => handleClusterClick(cluster.name)}
444
+ className="text-left p-4 bg-white dark:bg-zinc-900/50 hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-zinc-200 dark:border-zinc-800 hover:border-indigo-300 dark:hover:border-indigo-500/50 transition-all rounded-2xl group active:scale-95 duration-200 shadow-sm hover:shadow-md"
445
+ >
446
+ <h3 className="font-bold text-zinc-800 dark:text-zinc-200 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 truncate transition-colors">{cluster.name}</h3>
447
+ <p className="text-xs text-zinc-500 dark:text-zinc-500 mt-1 font-medium">{cluster.size} books</p>
448
+
449
+ <div className="flex mt-4 -space-x-3 overflow-hidden px-1 pb-1">
450
+ {cluster.top_books.slice(0, 3).map((book, i) => (
451
+ <div key={book.id} className={`w-8 h-12 bg-zinc-200 dark:bg-zinc-800 rounded-sm shadow-md border border-white/50 dark:border-zinc-700 relative z-${3-i} transform transition-transform group-hover:-translate-y-1 duration-300 overflow-hidden`} style={{transitionDelay: `${i*50}ms`}}>
452
+ <BookCover src={book.cover_image_url} title={book.title} author={book.authors[0]} className="w-full h-full" />
453
+ </div>
454
+ ))}
455
+ </div>
456
+ </button>
457
+ ))}
458
+ </div>
459
+ )}
460
+ </div>
461
+ )}
462
+ </div>
463
+
464
+ <div className="space-y-6 min-h-[50vh]">
465
+ {loading && results.length === 0 ? (
466
+ <div className="animate-fade-in pt-10">
467
+ <Loader />
468
+ </div>
469
+ ) : (
470
+ results.map((result) => (
471
+ <BookCard
472
+ key={result.book.id}
473
+ result={result}
474
+ isRead={readHistory.includes(result.book.title)}
475
+ onToggleRead={toggleReadBook}
476
+ onClick={() => handleReadMore(result)}
477
+ onFeedback={handleFeedback}
478
+ />
479
+ ))
480
+ )}
481
+ </div>
482
+
483
+ {hasSearched && results.length === 0 && !loading && (
484
+ <div className="text-center text-zinc-400 dark:text-zinc-600 py-12">
485
+ No books found matching that description.
486
+ </div>
487
+ )}
488
+ </main>
489
+
490
+ {selectedResult && (
491
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
492
+ <div className="absolute inset-0 bg-zinc-900/60 dark:bg-black/80 backdrop-blur-sm transition-opacity" onClick={closeModal} />
493
+ <div ref={modalRef} className="bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto relative animate-slide-up z-50 no-scrollbar border border-zinc-200 dark:border-zinc-800">
494
+ <button onClick={() => { triggerHaptic(); closeModal(); }} className="absolute right-4 top-4 p-2 bg-white/80 dark:bg-black/50 backdrop-blur hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-500 dark:text-zinc-400 rounded-full transition-colors z-10 shadow-sm">
495
+ <X className="w-5 h-5" />
496
+ </button>
497
+ {historyStack.length > 0 && (
498
+ <button onClick={handleBack} className="absolute left-4 top-4 p-2 bg-white/80 dark:bg-black/50 backdrop-blur hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-500 dark:text-zinc-400 rounded-full transition-colors z-10 shadow-sm flex items-center gap-1 pr-3">
499
+ <ArrowLeft className="w-5 h-5" />
500
+ <span className="text-xs font-bold">Back</span>
501
+ </button>
502
+ )}
503
+ <div className="grid sm:grid-cols-[220px_1fr] gap-0 sm:gap-8">
504
+ <div className="bg-zinc-100 dark:bg-zinc-800 h-64 sm:h-auto sm:aspect-[2/3] relative overflow-hidden">
505
+ <BookCover src={selectedResult.book.cover_image_url} title={selectedResult.book.title} author={selectedResult.book.authors[0]} className="w-full h-full" />
506
+ <div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent sm:hidden"></div>
507
+ <h2 className="absolute bottom-4 left-4 right-4 text-2xl font-bold font-serif text-white sm:hidden leading-tight shadow-black drop-shadow-md">{selectedResult.book.title}</h2>
508
+ </div>
509
+ <div className="p-6 sm:pl-0 sm:py-8 space-y-6">
510
+ <div className="hidden sm:block">
511
+ <h2 className="text-3xl sm:text-4xl font-bold font-serif leading-tight mb-2 text-zinc-900 dark:text-white">{selectedResult.book.title}</h2>
512
+ <p className="text-lg text-zinc-500 dark:text-zinc-400 font-medium">by {selectedResult.book.authors.join(', ')}</p>
513
+ </div>
514
+ <div className="prose prose-zinc dark:prose-invert prose-sm max-w-none leading-relaxed">
515
+ <p>{selectedResult.book.description}</p>
516
+ </div>
517
+ <div className="bg-indigo-50/50 dark:bg-indigo-900/20 rounded-2xl p-5 border border-indigo-100 dark:border-indigo-500/20">
518
+ <div className="flex items-center gap-2 mb-3 text-indigo-600 dark:text-indigo-400 font-bold text-xs uppercase tracking-widest">
519
+ <Sparkles className="w-4 h-4" />
520
+ Why this match?
521
+ </div>
522
+ {explaining ? (
523
+ <div className="flex items-center gap-2 text-indigo-400 dark:text-indigo-300 text-sm font-medium">
524
+ <Loader2 className="w-4 h-4 animate-spin" />
525
+ Reading your mind...
526
+ </div>
527
+ ) : explanation ? (
528
+ <div className="space-y-3">
529
+ <p className="text-sm text-zinc-800 dark:text-zinc-200 font-medium leading-relaxed">{explanation.summary}</p>
530
+ <div className="flex flex-wrap gap-2">
531
+ {Object.entries(explanation.details).map(([key, val]) => (
532
+ <span key={key} className="text-[10px] font-bold uppercase bg-white dark:bg-zinc-800 border border-indigo-100 dark:border-indigo-500/30 px-2 py-1 rounded-md text-indigo-600 dark:text-indigo-300 shadow-sm">
533
+ {key} {val}%
534
+ </span>
535
+ ))}
536
+ </div>
537
+ </div>
538
+ ) : (
539
+ <p className="text-sm text-red-400">Could not generate explanation.</p>
540
+ )}
541
+ </div>
542
+ <div className="pt-4 flex flex-col sm:flex-row gap-3">
543
+ <a href={`https://www.goodreads.com/search?q=${encodeURIComponent(selectedResult.book.title + ' ' + selectedResult.book.authors.join(', '))}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 px-6 py-3 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white border border-zinc-200 dark:border-zinc-700 font-bold rounded-full hover:bg-zinc-50 dark:hover:bg-zinc-700 hover:scale-105 transition-all active:scale-95 flex-1 justify-center shadow-sm" onClick={triggerHaptic}>
544
+ <BookOpen className="w-4 h-4" />
545
+ Goodreads
546
+ </a>
547
+ <a href={`https://www.google.com/search?q=${encodeURIComponent(selectedResult.book.title + ' by ' + selectedResult.book.authors.join(', '))}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 px-6 py-3 bg-zinc-900 dark:bg-indigo-600 text-white font-bold rounded-full hover:bg-black dark:hover:bg-indigo-700 hover:scale-105 transition-all active:scale-95 flex-1 justify-center shadow-xl shadow-zinc-900/20 dark:shadow-indigo-500/30" onClick={triggerHaptic}>
548
+ <Search className="w-4 h-4" />
549
+ Google
550
+ </a>
551
+ </div>
552
+ <div className="pt-8 border-t border-dashed border-zinc-200 dark:border-zinc-800">
553
+ <h3 className="text-xs font-bold text-zinc-400 dark:text-zinc-500 mb-4 uppercase tracking-widest">You might also like</h3>
554
+ {loadingRelated ? (
555
+ <div className="flex gap-4">
556
+ {[1, 2, 3].map(i => (
557
+ <div key={i} className="w-32 h-48 bg-zinc-100 dark:bg-zinc-800 rounded-xl animate-pulse" />
558
+ ))}
559
+ </div>
560
+ ) : (
561
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
562
+ {relatedBooks.map(rel => (
563
+ <div key={rel.book.id} onClick={() => handleReadMore(rel)} className="group cursor-pointer active:scale-95 transition-transform">
564
+ <div className="aspect-[2/3] bg-zinc-100 dark:bg-zinc-800 rounded-xl overflow-hidden mb-2 relative shadow-sm group-hover:shadow-md transition-all border border-zinc-100 dark:border-zinc-700">
565
+ <BookCover src={rel.book.cover_image_url} title={rel.book.title} author={rel.book.authors[0]} className="w-full h-full group-hover:scale-105 transition-transform duration-500" />
566
+ </div>
567
+ <h4 className="text-xs font-bold leading-tight text-zinc-800 dark:text-zinc-200 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors line-clamp-1">{rel.book.title}</h4>
568
+ <p className="text-[10px] text-zinc-400 dark:text-zinc-500 mt-0.5 truncate">{rel.book.authors[0]}</p>
569
+ </div>
570
+ ))}
571
+ </div>
572
+ )}
573
+ </div>
574
+ </div>
575
+ </div>
576
+ </div>
577
+ </div>
578
+ )}
579
+
580
+ {showAbout && (
581
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
582
+ <div className="absolute inset-0 bg-zinc-900/60 dark:bg-black/80 backdrop-blur-sm transition-opacity" onClick={() => setShowAbout(false)} />
583
+ <div className="bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto relative animate-slide-up z-50 no-scrollbar border border-zinc-200 dark:border-zinc-800">
584
+ <button onClick={() => { triggerHaptic(); setShowAbout(false); }} className="absolute right-4 top-4 p-2 bg-white/80 dark:bg-black/50 backdrop-blur hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-500 dark:text-zinc-400 rounded-full transition-colors z-10 shadow-sm">
585
+ <X className="w-5 h-5" />
586
+ </button>
587
+ <div className="p-6 sm:p-8 space-y-6">
588
+ <h3 className="text-2xl font-bold text-zinc-900 dark:text-white">About Serendipity</h3>
589
+ <div className="space-y-4 text-zinc-600 dark:text-zinc-300 leading-relaxed">
590
+ <p>
591
+ Serendipity is an intelligent book discovery interface powered by the <strong>DeepShelf Engine</strong>.
592
+ </p>
593
+ <p>
594
+ Unlike traditional keyword search, DeepShelf uses semantic vector embeddings to understand the <em>meaning</em> and <em>feeling</em> of your request. It connects you with books that match your specific wavelength, even if they don't share a single keyword.
595
+ </p>
596
+ </div>
597
+ <div className="space-y-3">
598
+ <h4 className="text-lg font-bold text-zinc-800 dark:text-zinc-200">How it works:</h4>
599
+ <ul className="list-disc list-inside text-zinc-600 dark:text-zinc-300 space-y-1">
600
+ <li><strong>Semantic Search:</strong> Understands the meaning and context of your queries.</li>
601
+ <li><strong>Vector Embeddings:</strong> DeepShelf maps books and queries into a high-dimensional vector space.</li>
602
+ <li><strong>Neural Retrieval:</strong> Finds the nearest neighbors to your thought in the library of 100,000+ books.</li>
603
+ </ul>
604
+ </div>
605
+ <p className="text-zinc-600 dark:text-zinc-300 leading-relaxed">
606
+ Describe your ideal read and let Serendipity uncover your next literary obsession!
607
+ </p>
608
+ </div>
609
+ </div>
610
+ </div>
611
+ )}
612
+ </div>
613
+ );
614
+ }
615
+
616
+ export default App;
frontend/src/BookCard.tsx ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { ThumbsUp, ThumbsDown, ArrowRight, Sparkles, Bookmark, Check } from 'lucide-react';
3
+ import { BookCover } from './BookCover';
4
+ import type { RecommendationResult } from './types';
5
+
6
+ interface BookCardProps {
7
+ result: RecommendationResult;
8
+ isRead?: boolean;
9
+ onToggleRead?: (title: string) => void;
10
+ onClick: () => void;
11
+ onFeedback: (id: string, type: 'positive' | 'negative') => void;
12
+ }
13
+
14
+ export function BookCard({ result, isRead, onToggleRead, onClick, onFeedback }: BookCardProps) {
15
+ const { book, similarity_score } = result;
16
+ const percentage = Math.round(similarity_score * 100);
17
+
18
+ const handleToggleRead = (e: React.MouseEvent) => {
19
+ e.stopPropagation();
20
+ if (onToggleRead) {
21
+ onToggleRead(book.title);
22
+ }
23
+ };
24
+
25
+ return (
26
+ <div
27
+ onClick={onClick}
28
+ className={`group relative bg-white dark:bg-zinc-900/80 backdrop-blur-sm border rounded-3xl p-4 sm:p-5 shadow-sm hover:shadow-xl hover:shadow-indigo-500/5 dark:hover:shadow-indigo-500/10 transition-all duration-300 cursor-pointer hover:-translate-y-0.5 active:scale-[0.99] animate-slide-up h-full flex flex-col sm:flex-row gap-5 overflow-hidden ${isRead ? 'border-indigo-500/50 dark:border-indigo-500/50 ring-1 ring-indigo-500/20' : 'border-zinc-200 dark:border-zinc-800'}`}
29
+ >
30
+ {/* Read Status Toggle (Absolute Top Right) */}
31
+ {onToggleRead && (
32
+ <button
33
+ onClick={handleToggleRead}
34
+ className={`absolute top-3 right-3 z-20 p-2 rounded-full transition-all shadow-sm ${isRead ? 'bg-indigo-600 text-white' : 'bg-white/80 dark:bg-zinc-800/80 text-zinc-400 hover:text-indigo-600 dark:hover:text-indigo-400 border border-zinc-200 dark:border-zinc-700'}`}
35
+ title={isRead ? "Remove from history" : "Mark as read"}
36
+ >
37
+ {isRead ? <Check className="w-4 h-4" /> : <Bookmark className="w-4 h-4" />}
38
+ </button>
39
+ )}
40
+
41
+ {/* Cover Image Section */}
42
+ <div className="w-full sm:w-28 aspect-[2/3] bg-zinc-100 dark:bg-zinc-800 rounded-xl overflow-hidden relative shadow-inner shrink-0 border border-zinc-100 dark:border-zinc-700">
43
+ <BookCover
44
+ src={book.cover_image_url}
45
+ title={book.title}
46
+ author={book.authors?.[0] || 'Unknown Author'}
47
+ className="w-full h-full transition-transform duration-500 group-hover:scale-105"
48
+ />
49
+
50
+ {/* Match Badge (Mobile Overlay) */}
51
+ <div className="absolute top-2 right-2 sm:hidden">
52
+ <span className="bg-white/90 dark:bg-black/80 backdrop-blur text-indigo-600 dark:text-indigo-400 text-[10px] font-bold px-2 py-1 rounded-full border border-black/5 dark:border-white/10 shadow-sm">
53
+ {percentage}% Match
54
+ </span>
55
+ </div>
56
+ </div>
57
+
58
+ {/* Content Section */}
59
+ <div className="space-y-2.5 flex-1 min-w-0 flex flex-col">
60
+ <div className="flex justify-between items-start gap-4">
61
+ <h2 className="text-xl font-bold leading-tight text-zinc-900 dark:text-zinc-100 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors line-clamp-2 font-serif tracking-tight">
62
+ {book.title}
63
+ </h2>
64
+ {/* Desktop Match Badge */}
65
+ <span className="hidden sm:inline-flex text-xs font-bold text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-100 dark:border-indigo-500/20 px-2 py-1 rounded-full whitespace-nowrap items-center gap-1">
66
+ <Sparkles className="w-3 h-3" />
67
+ {percentage}%
68
+ </span>
69
+ </div>
70
+
71
+ <p className="text-zinc-500 dark:text-zinc-400 font-medium text-xs">
72
+ by <span className="text-zinc-900 dark:text-zinc-200">{book.authors?.join(', ') || 'Unknown Author'}</span>
73
+ </p>
74
+
75
+ <p className="text-zinc-600 dark:text-zinc-400 leading-relaxed text-sm line-clamp-2 sm:line-clamp-3">
76
+ {book.description || 'No description available.'}
77
+ </p>
78
+
79
+ <div className="pt-2 mt-auto flex items-center justify-between gap-4">
80
+ {/* Mobile: Simple 'Read more' */}
81
+ <button className="flex items-center gap-1 text-sm font-bold text-indigo-600 dark:text-indigo-400 group-active:opacity-70">
82
+ Read more <ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-1" />
83
+ </button>
84
+
85
+ {/* Feedback Actions (Desktop Hover / Mobile Always Visible but subtle) */}
86
+ <div
87
+ className="flex gap-1 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
88
+ onClick={(e) => e.stopPropagation()}
89
+ >
90
+ <button
91
+ onClick={() => onFeedback(book.id, 'positive')}
92
+ className="p-2 hover:bg-green-50 dark:hover:bg-green-900/30 text-zinc-400 hover:text-green-600 dark:hover:text-green-400 rounded-full transition-colors"
93
+ aria-label="Like recommendation"
94
+ >
95
+ <ThumbsUp className="w-4 h-4" />
96
+ </button>
97
+ <button
98
+ onClick={() => onFeedback(book.id, 'negative')}
99
+ className="p-2 hover:bg-red-50 dark:hover:bg-red-900/30 text-zinc-400 hover:text-red-600 dark:hover:text-red-400 rounded-full transition-colors"
100
+ aria-label="Dislike recommendation"
101
+ >
102
+ <ThumbsDown className="w-4 h-4" />
103
+ </button>
104
+ </div>
105
+ </div>
106
+
107
+ {/* Genre Tags */}
108
+ <div className="flex flex-wrap gap-1.5 pt-2">
109
+ {book.genres?.slice(0, 3).map(genre => (
110
+ <span
111
+ key={genre}
112
+ className="text-[10px] uppercase tracking-wider font-bold text-zinc-500 dark:text-zinc-500 border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 px-2 py-1 rounded-md truncate max-w-[100px]"
113
+ title={genre}
114
+ >
115
+ {genre}
116
+ </span>
117
+ ))}
118
+ </div>
119
+ </div>
120
+ </div>
121
+ );
122
+ }
frontend/src/BookCover.tsx ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { BookOpen } from 'lucide-react';
3
+
4
+ // Deterministic color generator based on string
5
+ const getColor = (str: string) => {
6
+ const colors = [
7
+ 'from-red-500 to-orange-500',
8
+ 'from-orange-500 to-amber-500',
9
+ 'from-amber-500 to-yellow-500',
10
+ 'from-yellow-500 to-lime-500',
11
+ 'from-lime-500 to-green-500',
12
+ 'from-green-500 to-emerald-500',
13
+ 'from-emerald-500 to-teal-500',
14
+ 'from-teal-500 to-cyan-500',
15
+ 'from-cyan-500 to-sky-500',
16
+ 'from-sky-500 to-blue-500',
17
+ 'from-blue-500 to-indigo-500',
18
+ 'from-indigo-500 to-violet-500',
19
+ 'from-violet-500 to-purple-500',
20
+ 'from-purple-500 to-fuchsia-500',
21
+ 'from-fuchsia-500 to-pink-500',
22
+ 'from-pink-500 to-rose-500',
23
+ 'from-slate-500 to-zinc-500',
24
+ ];
25
+ let hash = 0;
26
+ for (let i = 0; i < str.length; i++) {
27
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
28
+ }
29
+ return colors[Math.abs(hash) % colors.length];
30
+ };
31
+
32
+ interface BookCoverProps {
33
+ src?: string | null;
34
+ title: string;
35
+ author?: string;
36
+ className?: string;
37
+ }
38
+
39
+ export function BookCover({ src, title, author, className = "" }: BookCoverProps) {
40
+ const [error, setError] = useState(false);
41
+ const gradient = getColor(title);
42
+
43
+ if (src && !error) {
44
+ return (
45
+ <img
46
+ src={src}
47
+ alt={title}
48
+ onError={() => setError(true)}
49
+ className={`object-cover ${className}`}
50
+ />
51
+ );
52
+ }
53
+
54
+ // Fallback: Generated Cover
55
+ return (
56
+ <div className={`bg-gradient-to-br ${gradient} p-4 flex flex-col justify-between relative overflow-hidden ${className}`}>
57
+ {/* Texture Overlay */}
58
+ <div className="absolute inset-0 bg-noise opacity-20 mix-blend-overlay"></div>
59
+
60
+ <div className="relative z-10">
61
+ <h3 className="text-white font-bold leading-tight text-shadow-sm line-clamp-4" style={{ fontSize: 'clamp(0.75rem, 1vw, 1rem)' }}>
62
+ {title}
63
+ </h3>
64
+ </div>
65
+
66
+ {author && (
67
+ <p className="relative z-10 text-white/90 text-[10px] font-medium truncate mt-2">
68
+ {author}
69
+ </p>
70
+ )}
71
+
72
+ <BookOpen className="absolute bottom-[-10%] right-[-10%] w-[60%] h-[60%] text-white/10 -rotate-12" />
73
+ </div>
74
+ );
75
+ }
frontend/src/Loader.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BookOpen } from 'lucide-react';
2
+ import { useState, useEffect } from 'react';
3
+
4
+ const LOADING_MESSAGES = [
5
+ "Scanning literary universe...",
6
+ "Analyzing plot vectors...",
7
+ "Connecting thematic dots...",
8
+ "Filtering hidden gems...",
9
+ "Synthesizing recommendations..."
10
+ ];
11
+
12
+ export function Loader() {
13
+ const [messageIndex, setMessageIndex] = useState(0);
14
+
15
+ useEffect(() => {
16
+ const interval = setInterval(() => {
17
+ setMessageIndex((prev) => (prev + 1) % LOADING_MESSAGES.length);
18
+ }, 800);
19
+ return () => clearInterval(interval);
20
+ }, []);
21
+
22
+ return (
23
+ <div className="flex flex-col items-center justify-center py-24 relative overflow-hidden">
24
+
25
+ {/* Orbiting / Pulsing Effect */}
26
+ <div className="relative flex items-center justify-center">
27
+ {/* Core */}
28
+ <div className="relative z-10 bg-white dark:bg-zinc-800 p-4 rounded-2xl shadow-xl border border-indigo-100 dark:border-indigo-500/30 animate-bounce-slight">
29
+ <BookOpen className="w-8 h-8 text-indigo-600 dark:text-indigo-400" />
30
+ </div>
31
+
32
+ {/* Orbital Ring 1 */}
33
+ <div className="absolute w-24 h-24 border-2 border-indigo-500/20 dark:border-indigo-400/20 rounded-full animate-spin-slow" style={{ borderRadius: '40% 60% 70% 30% / 40% 50% 60% 50%' }}></div>
34
+
35
+ {/* Orbital Ring 2 */}
36
+ <div className="absolute w-32 h-32 border border-purple-500/20 dark:border-purple-400/20 rounded-full animate-spin-reverse-slower" style={{ borderRadius: '60% 40% 30% 70% / 60% 30% 70% 40%' }}></div>
37
+
38
+ {/* Pulsing Aura */}
39
+ <div className="absolute inset-0 bg-indigo-500/10 dark:bg-indigo-400/10 rounded-full animate-ping-slow"></div>
40
+ </div>
41
+
42
+ {/* Text */}
43
+ <div className="mt-8 text-center z-10">
44
+ <p className="text-sm font-bold bg-clip-text text-transparent bg-gradient-to-r from-indigo-600 to-purple-600 dark:from-indigo-400 dark:to-purple-400 animate-pulse">
45
+ {LOADING_MESSAGES[messageIndex]}
46
+ </p>
47
+ </div>
48
+ </div>
49
+ );
50
+ }
frontend/src/api.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios';
2
+ import type { RecommendationResult, Book, BookCluster } from './types';
3
+
4
+ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
5
+
6
+ export const api = {
7
+ getClusters: async (): Promise<BookCluster[]> => {
8
+ const response = await axios.get<BookCluster[]>(`${API_URL}/clusters`);
9
+ return response.data;
10
+ },
11
+
12
+ recommendByQuery: async (query: string): Promise<RecommendationResult[]> => {
13
+ const response = await axios.post<RecommendationResult[]>(`${API_URL}/recommend/query`, {
14
+ query,
15
+ top_k: 12,
16
+ });
17
+ return response.data;
18
+ },
19
+
20
+ searchBooks: async (query: string): Promise<{ books: Book[] }> => {
21
+ const response = await axios.get(`${API_URL}/books/search`, {
22
+ params: { query, page: 1, page_size: 20 },
23
+ });
24
+ return response.data;
25
+ },
26
+
27
+ submitFeedback: async (query: string, bookId: string, type: 'positive' | 'negative') => {
28
+ await axios.post(`${API_URL}/feedback`, {
29
+ query,
30
+ book_id: bookId,
31
+ feedback_type: type,
32
+ });
33
+ },
34
+
35
+ explainRecommendation: async (query: string, book: Book, score: number) => {
36
+ const response = await axios.post(`${API_URL}/explain`, {
37
+ query_text: query,
38
+ recommended_book: book,
39
+ similarity_score: score
40
+ });
41
+ return response.data;
42
+ },
43
+
44
+ getRelatedBooks: async (title: string): Promise<RecommendationResult[]> => {
45
+ const response = await axios.post<RecommendationResult[]>(`${API_URL}/recommend/title`, {
46
+ title,
47
+ top_k: 6,
48
+ });
49
+ return response.data;
50
+ },
51
+
52
+ recommendPersonalized: async (history: string[]): Promise<RecommendationResult[]> => {
53
+ const response = await axios.post<RecommendationResult[]>(`${API_URL}/recommend/personalize`, {
54
+ user_history: history,
55
+ top_k: 12,
56
+ });
57
+ return response.data;
58
+ }
59
+ };
frontend/src/assets/react.svg ADDED
frontend/src/data/personas.ts ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface Persona {
2
+ id: string;
3
+ name: string;
4
+ emoji: string;
5
+ description: string;
6
+ history: string[];
7
+ }
8
+
9
+ export const DEMO_PERSONAS: Persona[] = [
10
+ {
11
+ id: 'scifi_fan',
12
+ name: 'The Futurist',
13
+ emoji: '🤖',
14
+ description: 'Loves cyberpunk, AI, and space opera.',
15
+ history: [
16
+ "Neuromancer",
17
+ "Snow Crash",
18
+ "Dune",
19
+ "I, Robot",
20
+ "Do Androids Dream of Electric Sheep?",
21
+ "Foundation"
22
+ ]
23
+ },
24
+ {
25
+ id: 'horror_fan',
26
+ name: 'The Thrill Seeker',
27
+ emoji: '👻',
28
+ description: 'Obsessed with ghosts, psychological terror, and Stephen King.',
29
+ history: [
30
+ "It",
31
+ "The Shining",
32
+ "Dracula",
33
+ "Pet Sematary",
34
+ "The Haunting of Hill House",
35
+ "Misery"
36
+ ]
37
+ },
38
+ {
39
+ id: 'romance_fan',
40
+ name: 'The Hopeless Romantic',
41
+ emoji: '💘',
42
+ description: 'Enjoys period dramas, deep emotions, and happy endings.',
43
+ history: [
44
+ "Pride and Prejudice",
45
+ "Jane Eyre",
46
+ "The Notebook",
47
+ "Sense and Sensibility",
48
+ "Me Before You",
49
+ "Outlander"
50
+ ]
51
+ },
52
+ {
53
+ id: 'history_buff',
54
+ name: 'The Time Traveler',
55
+ emoji: '📜',
56
+ description: 'Fascinated by WWII, ancient civilizations, and biographies.',
57
+ history: [
58
+ "The Diary of a Young Girl",
59
+ "The Guns of August",
60
+ "1776",
61
+ "Sapiens: A Brief History of Humankind",
62
+ "Alexander Hamilton"
63
+ ]
64
+ }
65
+ ];
frontend/src/index.css ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ --font-serif: "Crimson Pro", serif;
7
+ --font-sans: "Inter", sans-serif;
8
+ }
9
+
10
+ body {
11
+ font-family: var(--font-sans);
12
+ }
13
+
14
+ .font-serif {
15
+ font-family: var(--font-serif);
16
+ }
17
+
18
+ @layer utilities {
19
+ .animate-fade-in {
20
+ animation: fadeIn 0.5s ease-out forwards;
21
+ }
22
+
23
+ .animate-slide-up {
24
+ animation: slideUp 0.5s ease-out forwards;
25
+ }
26
+
27
+ .animation-delay-2000 {
28
+ animation-delay: 2s;
29
+ }
30
+
31
+ .animation-delay-4000 {
32
+ animation-delay: 4s;
33
+ }
34
+
35
+ .animation-delay-500 {
36
+ animation-delay: 500ms;
37
+ }
38
+
39
+ /* Custom Scrollbar Utilities */
40
+ .no-scrollbar::-webkit-scrollbar {
41
+ display: none;
42
+ }
43
+ .no-scrollbar {
44
+ -ms-overflow-style: none;
45
+ scrollbar-width: none;
46
+ }
47
+
48
+ .bg-noise {
49
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.4'/%3E%3C/svg%3E");
50
+ }
51
+
52
+ .bg-dot-pattern {
53
+ background-image: radial-gradient(circle, #6366f1 1.5px, transparent 1.5px);
54
+ background-size: 32px 32px;
55
+ opacity: 0.4;
56
+ }
57
+ .dark .bg-dot-pattern {
58
+ opacity: 0.2;
59
+ }
60
+ }
61
+
62
+ /* Global Custom Scrollbar */
63
+ ::-webkit-scrollbar {
64
+ width: 8px;
65
+ height: 8px;
66
+ }
67
+
68
+ ::-webkit-scrollbar-track {
69
+ background: transparent;
70
+ }
71
+
72
+ ::-webkit-scrollbar-thumb {
73
+ background-color: #d4d4d8; /* zinc-300 */
74
+ border-radius: 9999px;
75
+ border: 2px solid transparent;
76
+ background-clip: content-box;
77
+ }
78
+
79
+ .dark ::-webkit-scrollbar-thumb {
80
+ background-color: #3f3f46; /* zinc-700 */
81
+ }
82
+
83
+ ::-webkit-scrollbar-thumb:hover {
84
+ background-color: #a1a1aa; /* zinc-400 */
85
+ }
86
+
87
+ .dark ::-webkit-scrollbar-thumb:hover {
88
+ background-color: #52525b; /* zinc-600 */
89
+ }
90
+
91
+ @keyframes fadeIn {
92
+ from { opacity: 0; }
93
+ to { opacity: 1; }
94
+ }
95
+
96
+ @keyframes slideUp {
97
+ from {
98
+ opacity: 0;
99
+ transform: translateY(20px);
100
+ }
101
+ to {
102
+ opacity: 1;
103
+ transform: translateY(0);
104
+ }
105
+ }
106
+
107
+ @keyframes spin-slow {
108
+ from { transform: rotate(0deg); }
109
+ to { transform: rotate(360deg); }
110
+ }
111
+ .animate-spin-slow {
112
+ animation: spin-slow 8s linear infinite;
113
+ }
114
+
115
+ @keyframes spin-reverse-slower {
116
+ from { transform: rotate(360deg); }
117
+ to { transform: rotate(0deg); }
118
+ }
119
+ .animate-spin-reverse-slower {
120
+ animation: spin-reverse-slower 12s linear infinite;
121
+ }
122
+
123
+ @keyframes bounce-slight {
124
+ 0%, 100% { transform: translateY(0); }
125
+ 50% { transform: translateY(-5px); }
126
+ }
127
+ .animate-bounce-slight {
128
+ animation: bounce-slight 2s ease-in-out infinite;
129
+ }
130
+
131
+ @keyframes ping-slow {
132
+ 75%, 100% { transform: scale(2); opacity: 0; }
133
+ }
134
+ .animate-ping-slow {
135
+ animation: ping-slow 3s cubic-bezier(0, 0, 0.2, 1) infinite;
136
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.tsx'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
frontend/src/types.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface Book {
2
+ id: string;
3
+ title: string;
4
+ authors: string[];
5
+ description?: string;
6
+ genres: string[];
7
+ cover_image_url?: string;
8
+ }
9
+
10
+ export interface RecommendationResult {
11
+ book: Book;
12
+ similarity_score: number;
13
+ }
14
+
15
+ export interface RecommendByQueryRequest {
16
+ query: string;
17
+ top_k: number;
18
+ }
19
+
20
+ export interface BookCluster {
21
+ id: number;
22
+ name: string;
23
+ size: number;
24
+ top_books: Book[];
25
+ }
frontend/tailwind.config.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ darkMode: 'class',
4
+ content: [
5
+ "./index.html",
6
+ "./src/**/*.{js,ts,jsx,tsx}",
7
+ ],
8
+ theme: {
9
+ extend: {},
10
+ },
11
+ plugins: [],
12
+ }
frontend/tsconfig.app.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "types": ["vite/client"],
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "erasableSyntaxOnly": true,
24
+ "noFallthroughCasesInSwitch": true,
25
+ "noUncheckedSideEffectImports": true
26
+ },
27
+ "include": ["src"]
28
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
frontend/tsconfig.node.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "types": ["node"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "erasableSyntaxOnly": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedSideEffectImports": true
24
+ },
25
+ "include": ["vite.config.ts"]
26
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vite.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ build: {
8
+ rollupOptions: {
9
+ output: {
10
+ manualChunks: {
11
+ vendor: ['react', 'react-dom'],
12
+ },
13
+ },
14
+ },
15
+ },
16
+ })
logs/.gitkeep ADDED
File without changes
pyproject.toml ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "deepshelf-engine"
3
+ version = "0.1.0"
4
+ description = "DeepShelf - Semantic Book Recommendation Engine"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = {text = "MIT"}
8
+ authors = [
9
+ {name = "Your Name", email = "your.email@example.com"}
10
+ ]
11
+ keywords = [
12
+ "machine-learning",
13
+ "nlp",
14
+ "recommendation-system",
15
+ "fastapi",
16
+ "streamlit",
17
+ "books"
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Developers",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
28
+ ]
29
+
30
+ dependencies = [
31
+ "faiss-cpu>=1.13.0",
32
+ "numpy>=1.26.4",
33
+ "pandas>=2.3.3",
34
+ "pyarrow>=22.0.0",
35
+ "requests>=2.32.5",
36
+ "scikit-learn>=1.7.2",
37
+ "sentence-transformers==2.5.1",
38
+ "streamlit>=1.40.0",
39
+ "torch==2.2.2",
40
+ "transformers==4.38.2",
41
+ "fastapi>=0.104.1",
42
+ "uvicorn[standard]>=0.24.0",
43
+ "pydantic>=2.5.2",
44
+ "python-dotenv>=1.0.0",
45
+ "plotly>=5.18.0",
46
+ "slowapi>=0.1.9",
47
+ "pytest>=9.0.1",
48
+ "groq>=0.36.0",
49
+ ]
50
+
51
+ [project.optional-dependencies]
52
+ dev = [
53
+ "pytest>=9.0.1",
54
+ "pytest-cov>=4.1.0",
55
+ "ruff>=0.1.0",
56
+ "black>=23.0.0",
57
+ "mypy>=1.7.0",
58
+ "safety>=2.3.0",
59
+ "pip-audit>=2.6.0",
60
+ "pre-commit>=3.5.0",
61
+ ]
62
+
63
+ [project.urls]
64
+ Homepage = "https://github.com/yourusername/bookfinder-ai"
65
+ Documentation = "https://github.com/yourusername/bookfinder-ai/tree/main/docs"
66
+ Repository = "https://github.com/yourusername/bookfinder-ai"
67
+ "Bug Tracker" = "https://github.com/yourusername/bookfinder-ai/issues"
68
+
69
+ [project.scripts]
70
+ bookfinder-web = "src.book_recommender.apps.main_app:main"
71
+ bookfinder-api = "src.book_recommender.api.main:main"
72
+ bookfinder-analytics = "src.book_recommender.apps.analytics_app:main"
73
+
74
+ [build-system]
75
+ requires = ["hatchling"]
76
+ build-backend = "hatchling.build"
77
+
78
+ [tool.hatch.build.targets.wheel]
79
+ packages = ["src/book_recommender"]
80
+
81
+
82
+
83
+
84
+
85
+ [tool.ruff]
86
+ line-length = 120
87
+ target-version = "py310"
88
+
89
+ [tool.ruff.lint]
90
+ select = ["E", "F", "W", "I"]
91
+ ignore = ["E203", "E501"]
92
+
93
+ [tool.black]
94
+ line-length = 120
95
+ target-version = ["py310", "py311", "py312"]
96
+
97
+ [tool.mypy]
98
+ python_version = "3.10"
99
+ warn_return_any = true
100
+ warn_unused_configs = true
101
+ disallow_untyped_defs = false
102
+ ignore_missing_imports = true
103
+
104
+ [[tool.mypy.overrides]]
105
+ module = "requests.*"
106
+ ignore_missing_imports = true
requirements-dev.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # For running tests
2
+ pytest-cov
3
+
4
+ # For UI testing (optional)
5
+ # streamlit-testing-library
6
+
7
+ black
8
+ ruff
9
+ mypy
requirements.backend.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cpu
2
+ torch==2.2.2
3
+ sentence-transformers==2.5.1
4
+ transformers==4.38.2
5
+ pandas
6
+ numpy<2.0.0
7
+ scikit-learn
8
+ pyarrow
9
+ faiss-cpu
10
+ requests
11
+ fastapi
12
+ uvicorn[standard]
13
+ pydantic
14
+ python-dotenv
15
+ slowapi
16
+ groq
17
+ huggingface_hub<1.0
requirements.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cpu
2
+ torch==2.2.2
3
+ streamlit>=1.40.0
4
+ sentence-transformers==2.5.1
5
+ transformers==4.38.2
6
+ pandas
7
+ numpy
8
+ scikit-learn
9
+ pytest
10
+ pyarrow
11
+ faiss-cpu
12
+ requests
13
+ fastapi
14
+ uvicorn[standard]
15
+ pydantic
16
+ python-dotenv
17
+ slowapi
18
+ groq
19
+ huggingface_hub
scripts/download_data.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from huggingface_hub import snapshot_download
7
+
8
+ # Configure logging
9
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Define paths directly to avoid importing from src (which isn't copied yet in Docker build)
13
+ PROCESSED_DATA_DIR = Path("data/processed")
14
+ PROCESSED_DATA_PATH = PROCESSED_DATA_DIR / "books_with_embeddings.parquet"
15
+ EMBEDDINGS_PATH = PROCESSED_DATA_DIR / "embeddings.npy"
16
+ CLUSTERS_CACHE_PATH = PROCESSED_DATA_DIR / "clusters_cache.pkl"
17
+
18
+ def download_processed_data(repo_id: str):
19
+ """
20
+ Downloads processed data files (parquet, npy, pkl) from a private Hugging Face Dataset.
21
+
22
+ Args:
23
+ repo_id (str): The Hugging Face dataset ID (e.g., 'username/dataset-name').
24
+ """
25
+ hf_token = os.getenv("HF_TOKEN")
26
+ if not hf_token:
27
+ logger.warning("HF_TOKEN environment variable not found. If the dataset is private, download will fail.")
28
+
29
+ logger.info(f"Starting download from Hugging Face Dataset: {repo_id}")
30
+ logger.info(f"Target directory: {PROCESSED_DATA_DIR}")
31
+
32
+ try:
33
+ # Ensure directory exists
34
+ PROCESSED_DATA_DIR.mkdir(parents=True, exist_ok=True)
35
+
36
+ # Download only specific files to avoid clutter
37
+ allow_patterns = [
38
+ "*.parquet",
39
+ "*.npy",
40
+ "*.pkl",
41
+ "*.json"
42
+ ]
43
+
44
+ snapshot_download(
45
+ repo_id=repo_id,
46
+ repo_type="dataset",
47
+ local_dir=PROCESSED_DATA_DIR,
48
+ local_dir_use_symlinks=False, # Important for Docker/Deployment
49
+ allow_patterns=allow_patterns,
50
+ token=hf_token
51
+ )
52
+
53
+ logger.info("Successfully downloaded all data files.")
54
+
55
+ # Verify files
56
+ expected_files = [
57
+ PROCESSED_DATA_PATH,
58
+ EMBEDDINGS_PATH,
59
+ CLUSTERS_CACHE_PATH
60
+ ]
61
+
62
+ missing = [f.name for f in expected_files if not f.exists()]
63
+ if missing:
64
+ logger.error(f"Warning: The following expected files are still missing after download: {missing}")
65
+ else:
66
+ logger.info("Verification successful: All core data files are present.")
67
+
68
+ except Exception as e:
69
+ logger.error(f"Failed to download data from Hugging Face: {e}")
70
+ sys.exit(1)
71
+
72
+ if __name__ == "__main__":
73
+ # Default repo ID - WILL BE OVERRIDDEN by environment variable in production
74
+ DEFAULT_REPO_ID = "nice-bill/book-recommender-data"
75
+
76
+ repo_id = os.getenv("HF_DATASET_ID", DEFAULT_REPO_ID)
77
+
78
+ if repo_id == "PLACEHOLDER_USERNAME/PLACEHOLDER_DATASET":
79
+ logger.error("Please set the HF_DATASET_ID environment variable or update the script.")
80
+ sys.exit(1)
81
+
82
+ download_processed_data(repo_id)
scripts/download_model.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+
4
+ from sentence_transformers import SentenceTransformer
5
+
6
+ # sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
7
+ # from src.book_recommender.core import config
8
+
9
+ MODEL_NAME = "all-MiniLM-L6-v2"
10
+
11
+ def download_model():
12
+ """
13
+ Downloads the sentence-transformer model specified in the config file.
14
+ This is useful for pre-downloading the model in a Docker build.
15
+ """
16
+ print(f"Downloading model: {MODEL_NAME}...")
17
+ _ = SentenceTransformer(MODEL_NAME)
18
+ print("Model downloaded successfully.")
19
+
20
+
21
+ if __name__ == "__main__":
22
+ download_model()
scripts/enrich_book_covers.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import requests
3
+ import time
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
8
+ logger = logging.getLogger(__name__)
9
+
10
+ DATA_DIR = Path("data/raw")
11
+ INPUT_FILE = DATA_DIR / "books_prepared.csv"
12
+
13
+ def get_openlibrary_cover(title, author):
14
+ try:
15
+ # Simple cleaning
16
+ clean_title = title.replace('&', '').split('(')[0].strip()
17
+ clean_author = author.split(',')[0].strip() if author else ""
18
+
19
+ query = f"title={clean_title}&author={clean_author}"
20
+ url = f"https://openlibrary.org/search.json?{query}&limit=1"
21
+
22
+ response = requests.get(url, timeout=5)
23
+ if response.status_code == 200:
24
+ data = response.json()
25
+ if data.get("docs"):
26
+ doc = data["docs"][0]
27
+ if "cover_i" in doc:
28
+ return f"https://covers.openlibrary.org/b/id/{doc['cover_i']}-L.jpg"
29
+ except Exception as e:
30
+ logger.warning(f"Error fetching cover for {title}: {e}")
31
+ return None
32
+
33
+ def enrich_data():
34
+ if not INPUT_FILE.exists():
35
+ logger.error(f"File not found: {INPUT_FILE}")
36
+ return
37
+
38
+ df = pd.read_csv(INPUT_FILE)
39
+ logger.info(f"Loaded {len(df)} books.")
40
+
41
+ if "cover_image_url" not in df.columns:
42
+ df["cover_image_url"] = None
43
+
44
+ # Filter for rows without covers
45
+ # We check for NaN or empty string
46
+ mask = df["cover_image_url"].isna() | (df["cover_image_url"] == "")
47
+ indices = df[mask].index
48
+
49
+ logger.info(f"Found {len(indices)} books missing covers.")
50
+
51
+ # Process a batch (e.g., 50) to demonstrate improvement without timeout
52
+ # The user can run this script repeatedly or increase limit
53
+ BATCH_SIZE = 20
54
+ count = 0
55
+
56
+ for idx in indices:
57
+ if count >= BATCH_SIZE:
58
+ break
59
+
60
+ row = df.loc[idx]
61
+ title = row['title']
62
+ author = row['authors']
63
+
64
+ logger.info(f"[{count+1}/{BATCH_SIZE}] Fetching cover for: {title}")
65
+ cover_url = get_openlibrary_cover(title, author)
66
+
67
+ if cover_url:
68
+ df.at[idx, 'cover_image_url'] = cover_url
69
+ logger.info(f" -> Found: {cover_url}")
70
+ else:
71
+ logger.info(" -> No cover found.")
72
+
73
+ time.sleep(0.2) # Polite delay
74
+ count += 1
75
+
76
+ # Save back
77
+ df.to_csv(INPUT_FILE, index=False)
78
+ logger.info(f"Saved enriched data to {INPUT_FILE}")
79
+
80
+ if __name__ == "__main__":
81
+ enrich_data()
scripts/precompute_clusters.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import pickle
4
+ import sys
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+ # Add project root
9
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
10
+
11
+ import src.book_recommender.core.config as config
12
+ from src.book_recommender.ml.clustering import cluster_books, get_cluster_names
13
+
14
+ # Configure logging
15
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
16
+ logger = logging.getLogger(__name__)
17
+
18
+ def precompute_clusters():
19
+ logger.info("--- Starting Cluster Pre-computation ---")
20
+
21
+ # 1. Load Data
22
+ if not os.path.exists(config.PROCESSED_DATA_PATH) or not os.path.exists(config.EMBEDDINGS_PATH):
23
+ logger.error("Data files missing. Run data processor and embedder first.")
24
+ return
25
+
26
+ logger.info(f"Loading book data from {config.PROCESSED_DATA_PATH}...")
27
+ book_data_df = pd.read_parquet(config.PROCESSED_DATA_PATH)
28
+
29
+ logger.info(f"Loading embeddings from {config.EMBEDDINGS_PATH}...")
30
+ embeddings_arr = np.load(config.EMBEDDINGS_PATH)
31
+
32
+ # 2. Cluster
33
+ n_clusters = config.NUM_CLUSTERS
34
+ logger.info(f"Clustering {len(book_data_df)} books into {n_clusters} clusters...")
35
+
36
+ clusters_arr, _ = cluster_books(embeddings_arr, n_clusters=n_clusters)
37
+
38
+ # 3. Name Clusters
39
+ book_data_df["cluster_id"] = clusters_arr
40
+ names = get_cluster_names(book_data_df, clusters_arr)
41
+
42
+ # 4. Save Cache
43
+ cache_path = config.PROCESSED_DATA_DIR / "cluster_cache.pkl"
44
+ logger.info(f"Saving cache to {cache_path}...")
45
+
46
+ try:
47
+ with open(cache_path, "wb") as f:
48
+ # Must match the tuple structure expected by api/dependencies.py
49
+ # (clusters_arr, names, book_data_df)
50
+ pickle.dump((clusters_arr, names, book_data_df), f)
51
+ logger.info("Successfully pre-computed and cached clusters.")
52
+ except Exception as e:
53
+ logger.error(f"Failed to save cache: {e}")
54
+
55
+ if __name__ == "__main__":
56
+ precompute_clusters()
scripts/prepare_100k_data.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import sys
4
+ import pandas as pd
5
+
6
+ # Setup logging
7
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
8
+ logger = logging.getLogger(__name__)
9
+
10
+ def prepare_100k_data():
11
+ """
12
+ Convert GoodReads_100k_books.csv format to the format expected by data_processor.
13
+
14
+ Input Columns: author, bookformat, desc, genre, img, isbn, isbn13, link, pages, rating, reviews, title, totalratings
15
+ Target Columns: title, authors, genres, description, tags, rating, cover_image_url
16
+ """
17
+ input_path = "data/raw/GoodReads_100k_books.csv"
18
+ output_path = "data/raw/books_prepared.csv"
19
+
20
+ logger.info(f"Loading new 100k dataset from {input_path}...")
21
+
22
+ if not os.path.exists(input_path):
23
+ logger.error(f"Input file not found: {input_path}")
24
+ return
25
+
26
+ try:
27
+ # Load data
28
+ df = pd.read_csv(input_path)
29
+ logger.info(f"Loaded {len(df)} books.")
30
+
31
+ # Rename columns
32
+ logger.info("Mapping columns...")
33
+ df = df.rename(columns={
34
+ "author": "authors",
35
+ "desc": "description",
36
+ "genre": "genres",
37
+ "img": "cover_image_url",
38
+ "rating": "rating"
39
+ })
40
+
41
+ # Create tags column (using genres as base if available, else empty)
42
+ df["tags"] = df["genres"].fillna("")
43
+
44
+ # Select and Reorder
45
+ target_cols = ["title", "authors", "genres", "description", "tags", "rating", "cover_image_url"]
46
+
47
+ # Ensure all target columns exist
48
+ for col in target_cols:
49
+ if col not in df.columns:
50
+ df[col] = ""
51
+ logger.warning(f"Column {col} missing in source, filled with empty strings.")
52
+
53
+ df = df[target_cols]
54
+
55
+ # Clean up
56
+ logger.info("Cleaning data...")
57
+ # Remove rows with no title
58
+ df = df.dropna(subset=["title"])
59
+ # Fill NaNs in text columns
60
+ df[["authors", "genres", "description", "cover_image_url"]] = df[["authors", "genres", "description", "cover_image_url"]].fillna("")
61
+
62
+ logger.info(f"Saving prepared data to {output_path}...")
63
+ df.to_csv(output_path, index=False)
64
+ logger.info(f"Successfully prepared {len(df)} books.")
65
+
66
+ except Exception as e:
67
+ logger.error(f"Error processing data: {e}")
68
+ raise
69
+
70
+ if __name__ == "__main__":
71
+ prepare_100k_data()
scripts/prepare_goodreads_data.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import sys
4
+
5
+ import pandas as pd
6
+
7
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
8
+
9
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def prepare_goodreads_data():
14
+ """
15
+ Convert Goodreads CSV format to the format expected by data_processor.
16
+
17
+ Maps:
18
+ - Book → title
19
+ - Author → authors
20
+ - Avg_Rating → rating
21
+ - Description → description
22
+ - Genres → genres
23
+ - Creates 'tags' from genres
24
+ - Drops: Unnamed: 0, Num_Ratings, URL
25
+ """
26
+ input_path = "data/raw/goodreads_data.csv"
27
+ output_path = "data/raw/books_prepared.csv"
28
+
29
+ logger.info(f"Loading Goodreads data from {input_path}...")
30
+
31
+ try:
32
+ df = pd.read_csv(input_path)
33
+ logger.info(f"Loaded {len(df)} books")
34
+ logger.info(f"Original columns: {df.columns.tolist()}")
35
+
36
+ if "Unnamed: 0" in df.columns or "" in df.columns:
37
+ df = df.drop(columns=[col for col in df.columns if "Unnamed" in str(col) or col == ""])
38
+ logger.info("Dropped unnamed index column")
39
+
40
+ logger.info("Renaming columns...")
41
+ df = df.rename(
42
+ columns={
43
+ "Book": "title",
44
+ "Author": "authors",
45
+ "Avg_Rating": "rating",
46
+ "Description": "description",
47
+ "Genres": "genres",
48
+ }
49
+ )
50
+
51
+ df["tags"] = ""
52
+ logger.info("Created 'tags' column (empty - can be populated later)")
53
+
54
+ columns_to_keep = ["title", "authors", "genres", "description", "tags", "rating"]
55
+
56
+ missing_cols = [col for col in columns_to_keep if col not in df.columns]
57
+ if missing_cols:
58
+ logger.error(f"Missing columns after mapping: {missing_cols}")
59
+ logger.error(f"Available columns: {df.columns.tolist()}")
60
+ raise ValueError(f"Column mapping failed. Missing: {missing_cols}")
61
+
62
+ df = df[columns_to_keep]
63
+
64
+ logger.info(f"Final shape: {df.shape}")
65
+ logger.info(f"Final columns: {df.columns.tolist()}")
66
+ logger.info(f"Sample row:\n{df.iloc[0]}")
67
+
68
+ null_rows = df.isnull().all(axis=1).sum()
69
+ if null_rows > 0:
70
+ logger.warning(f"Found {null_rows} completely null rows - will be removed by processor")
71
+
72
+ logger.info(f"Saving prepared data to {output_path}...")
73
+ df.to_csv(output_path, index=False)
74
+ logger.info(f" Successfully prepared {len(df)} books")
75
+
76
+ logger.info("\n Dataset Summary:")
77
+ logger.info(f" Total books: {len(df)}")
78
+ logger.info(f" Books with ratings: {df['rating'].notna().sum()}")
79
+ logger.info(f" Books with descriptions: {(df['description'] != '').sum()}")
80
+ logger.info(f" Average rating: {df['rating'].mean():.2f}")
81
+
82
+ print("\n Data preparation complete!")
83
+ print(f" Input: {input_path}")
84
+ print(f" Output: {output_path}")
85
+ print("\n Next steps:")
86
+ print(" 1. Update src/config.py line 17:")
87
+ print(" RAW_DATA_PATH = os.path.join(RAW_DATA_DIR, 'books_prepared.csv')")
88
+ print(" 2. Run: python src/data_processor.py")
89
+ print(" 3. Run: python src/embedder.py")
90
+ print(" 4. Run: streamlit run app.py")
91
+
92
+ return df
93
+
94
+ except FileNotFoundError:
95
+ logger.error(f"File not found: {input_path}")
96
+ logger.error("Make sure goodreads_data.csv exists in data/raw/")
97
+ raise
98
+ except Exception as e:
99
+ logger.error(f"Error processing data: {e}")
100
+ raise
101
+
102
+
103
+ if __name__ == "__main__":
104
+ prepare_goodreads_data()
src/__init__.py ADDED
File without changes
src/book_recommender/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BookFinder-AI - Semantic Book Recommendation Engine"""
2
+
3
+ __version__ = "1.0.0"
4
+ __author__ = "Your Name"
5
+
6
+ import os
7
+ import sys
8
+
9
+ # Add the project root to the Python path
10
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
11
+
12
+
13
+ # Expose main classes at package level
14
+ from src.book_recommender.ml.clustering import cluster_books, get_cluster_names
15
+ from src.book_recommender.ml.embedder import generate_embedding_for_query
16
+ from src.book_recommender.ml.recommender import BookRecommender
17
+
18
+ __all__ = [
19
+ "BookRecommender",
20
+ "generate_embedding_for_query",
21
+ "cluster_books",
22
+ "get_cluster_names",
23
+ ]