Spaces:
Running
Running
initial commit
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .devcontainer/devcontainer.json +33 -0
- .dockerignore +31 -0
- .env.example +10 -0
- .gitattributes +2 -33
- .gitignore +85 -0
- .python-version +1 -0
- .streamlit/config.toml +9 -0
- DEPLOYMENT_NOTES.md +69 -0
- Dockerfile.backend +74 -0
- Makefile +53 -0
- README.md +162 -1
- data/feedback/user_feedback.jsonl +4 -0
- docs/ARCHITECTURE.md +176 -0
- frontend/.gitignore +24 -0
- frontend/README.md +73 -0
- frontend/eslint.config.js +23 -0
- frontend/index.html +16 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +38 -0
- frontend/postcss.config.js +6 -0
- frontend/public/vite.svg +1 -0
- frontend/src/App.css +42 -0
- frontend/src/App.tsx +616 -0
- frontend/src/BookCard.tsx +122 -0
- frontend/src/BookCover.tsx +75 -0
- frontend/src/Loader.tsx +50 -0
- frontend/src/api.ts +59 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/data/personas.ts +65 -0
- frontend/src/index.css +136 -0
- frontend/src/main.tsx +10 -0
- frontend/src/types.ts +25 -0
- frontend/tailwind.config.js +12 -0
- frontend/tsconfig.app.json +28 -0
- frontend/tsconfig.json +7 -0
- frontend/tsconfig.node.json +26 -0
- frontend/vite.config.ts +16 -0
- logs/.gitkeep +0 -0
- pyproject.toml +106 -0
- requirements-dev.txt +9 -0
- requirements.backend.txt +17 -0
- requirements.txt +19 -0
- scripts/download_data.py +82 -0
- scripts/download_model.py +22 -0
- scripts/enrich_book_covers.py +81 -0
- scripts/precompute_clusters.py +56 -0
- scripts/prepare_100k_data.py +71 -0
- scripts/prepare_goodreads_data.py +104 -0
- src/__init__.py +0 -0
- 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 |
-
*.
|
| 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 |
-
*.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
short_description: Semantic Book Recommendation API powered by DeepShelf.
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Serendipity (Book Discovery Platform)
|
| 13 |
+
|
| 14 |
+
   
|
| 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 |
+
]
|