Spaces:
Sleeping
Sleeping
refactor: migrate to React-only frontend and optimize UI layout
Browse files- .gitignore +2 -0
- CHANGELOG.md +41 -14
- Dockerfile +3 -4
- Makefile +0 -3
- PHASE_2_DEVELOPMENT.md +509 -0
- README.md +18 -15
- REVIEW_HIGHLIGHTS.md +142 -0
- TAGS_AND_EMOTIONS.md +233 -0
- data/user_profiles.json +7 -0
- docker-compose.yml +0 -14
- interview_prep.md +2 -2
- scripts/extract_review_sentences.py +252 -0
- scripts/generate_emotions.py +163 -0
- scripts/generate_tags.py +153 -0
- src/cover_fetcher.py +38 -34
- src/main.py +73 -1
- src/marketing/highlights.py +101 -0
- src/marketing/persona.py +55 -0
- src/recommender.py +26 -2
- src/user/profile_store.py +60 -0
- web/README.md +36 -0
- web/index.html +13 -0
- web/package-lock.json +1054 -0
- web/package.json +19 -0
- web/src/App.jsx +401 -0
- web/src/api.js +39 -0
- web/src/main.jsx +9 -0
.gitignore
CHANGED
|
@@ -72,3 +72,5 @@ logs/
|
|
| 72 |
data/chroma_db/
|
| 73 |
|
| 74 |
|
|
|
|
|
|
|
|
|
| 72 |
data/chroma_db/
|
| 73 |
|
| 74 |
|
| 75 |
+
web/node_modules/
|
| 76 |
+
data/books_processed.csv
|
CHANGELOG.md
CHANGED
|
@@ -4,7 +4,35 @@ All notable changes to this project will be documented in this file.
|
|
| 4 |
|
| 5 |
## [Unreleased]
|
| 6 |
|
| 7 |
-
### Added -
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
- **Real-time Book Cover Fetching**: New `src/cover_fetcher.py` module that fetches book covers dynamically from Google Books API and Open Library
|
| 9 |
- LRU cache (1000 items) to avoid redundant API calls
|
| 10 |
- Automatic fallback to Open Library if Google Books fails
|
|
@@ -12,25 +40,24 @@ All notable changes to this project will be documented in this file.
|
|
| 12 |
- ~0.5-1s latency increase per recommendation query (10-20 books)
|
| 13 |
- **Client-Server Architecture**: Separated UI and API into independent processes
|
| 14 |
- API server runs on port 6006 (FastAPI backend)
|
| 15 |
-
-
|
| 16 |
- Enables better scalability and deployment flexibility
|
| 17 |
|
| 18 |
-
### Changed -
|
| 19 |
-
- **
|
| 20 |
-
-
|
| 21 |
-
-
|
| 22 |
-
-
|
| 23 |
-
- Fixed payload format to match API schema (query, category, tone)
|
| 24 |
- **Makefile**: Updated `run` command to explicitly use port 6006 for API server
|
| 25 |
- **src/recommender.py**: Integrated real-time cover fetcher in `_format_results()`
|
| 26 |
- Replaced hardcoded file paths with dynamic API calls
|
| 27 |
- Each recommendation now fetches fresh cover URLs
|
|
|
|
| 28 |
|
| 29 |
-
### Fixed -
|
| 30 |
- Port mismatch between API (8000) and UI (expected 6006)
|
| 31 |
-
-
|
| 32 |
-
-
|
| 33 |
-
- Response structure mismatch (direct list vs {recommendations: []} object)
|
| 34 |
|
| 35 |
### Added
|
| 36 |
- **Super App Architecture**: Transformed into "End-to-End AI E-Commerce Platform" with 3-tab UI.
|
|
@@ -52,8 +79,8 @@ All notable changes to this project will be documented in this file.
|
|
| 52 |
- Updated README with project structure section
|
| 53 |
|
| 54 |
### Fixed
|
| 55 |
-
-
|
| 56 |
-
- Dockerfile startup command (
|
| 57 |
|
| 58 |
---
|
| 59 |
|
|
|
|
| 4 |
|
| 5 |
## [Unreleased]
|
| 6 |
|
| 7 |
+
### Added - 2024-01-07
|
| 8 |
+
- **UI Refinements**: Book detail modal layout improvements
|
| 9 |
+
- Author name displayed separately below book cover
|
| 10 |
+
- Optimized spacing between elements (reduced excessive whitespace)
|
| 11 |
+
- Removed mood/emotion display from detail modal for cleaner interface
|
| 12 |
+
- Review highlights positioned directly after AI highlight box
|
| 13 |
+
|
| 14 |
+
### Added - 2024-01-XX
|
| 15 |
+
- **Review Highlights Feature**: Semantic sentence extraction with clustering
|
| 16 |
+
- scripts/extract_review_sentences.py for processing book descriptions
|
| 17 |
+
- Review highlights display in React frontend
|
| 18 |
+
- Average rating display in book detail modal
|
| 19 |
+
- REVIEW_HIGHLIGHTS.md documentation
|
| 20 |
+
|
| 21 |
+
### Changed - 2024-01-XX
|
| 22 |
+
- **Frontend Migration**: Moved from dual UI (Gradio + React) to React-only
|
| 23 |
+
- Updated README.md with React frontend setup instructions
|
| 24 |
+
- Updated Dockerfile to run FastAPI backend (port 8000)
|
| 25 |
+
- Updated docker-compose.yml to remove Gradio service
|
| 26 |
+
- Cleaned up documentation references to Gradio
|
| 27 |
+
|
| 28 |
+
### Removed - 2024-01-XX
|
| 29 |
+
- app.py (264-line Gradio legacy UI)
|
| 30 |
+
- Makefile run-ui target
|
| 31 |
+
- docker-compose.yml ui service definition
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
### Added - 2024-01-06
|
| 36 |
- **Real-time Book Cover Fetching**: New `src/cover_fetcher.py` module that fetches book covers dynamically from Google Books API and Open Library
|
| 37 |
- LRU cache (1000 items) to avoid redundant API calls
|
| 38 |
- Automatic fallback to Open Library if Google Books fails
|
|
|
|
| 40 |
- ~0.5-1s latency increase per recommendation query (10-20 books)
|
| 41 |
- **Client-Server Architecture**: Separated UI and API into independent processes
|
| 42 |
- API server runs on port 6006 (FastAPI backend)
|
| 43 |
+
- React frontend runs on port 5173 (development)
|
| 44 |
- Enables better scalability and deployment flexibility
|
| 45 |
|
| 46 |
+
### Changed - 2024-01-06
|
| 47 |
+
- **React Frontend (web/)**: Created modern UI with book search and recommendations
|
| 48 |
+
- React 18 + Vite for fast development
|
| 49 |
+
- Tailwind CSS for styling
|
| 50 |
+
- Book detail modal with review highlights
|
|
|
|
| 51 |
- **Makefile**: Updated `run` command to explicitly use port 6006 for API server
|
| 52 |
- **src/recommender.py**: Integrated real-time cover fetcher in `_format_results()`
|
| 53 |
- Replaced hardcoded file paths with dynamic API calls
|
| 54 |
- Each recommendation now fetches fresh cover URLs
|
| 55 |
+
- Added review_highlights and average_rating fields
|
| 56 |
|
| 57 |
+
### Fixed - 2024-01-06
|
| 58 |
- Port mismatch between API (8000) and UI (expected 6006)
|
| 59 |
+
- API validation errors due to payload field name mismatch
|
| 60 |
+
- Response structure improvements for frontend integration
|
|
|
|
| 61 |
|
| 62 |
### Added
|
| 63 |
- **Super App Architecture**: Transformed into "End-to-End AI E-Commerce Platform" with 3-tab UI.
|
|
|
|
| 79 |
- Updated README with project structure section
|
| 80 |
|
| 81 |
### Fixed
|
| 82 |
+
- React 18 compatibility issues
|
| 83 |
+
- Dockerfile startup command (updated to run FastAPI backend)
|
| 84 |
|
| 85 |
---
|
| 86 |
|
Dockerfile
CHANGED
|
@@ -19,9 +19,8 @@ COPY . .
|
|
| 19 |
ENV PYTHONUNBUFFERED=1
|
| 20 |
ENV PYTHONPATH=/app
|
| 21 |
|
| 22 |
-
# Expose
|
| 23 |
EXPOSE 8000
|
| 24 |
-
EXPOSE 7860
|
| 25 |
|
| 26 |
-
# Default command: Run
|
| 27 |
-
CMD ["
|
|
|
|
| 19 |
ENV PYTHONUNBUFFERED=1
|
| 20 |
ENV PYTHONPATH=/app
|
| 21 |
|
| 22 |
+
# Expose port for API
|
| 23 |
EXPOSE 8000
|
|
|
|
| 24 |
|
| 25 |
+
# Default command: Run FastAPI backend
|
| 26 |
+
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
Makefile
CHANGED
|
@@ -6,9 +6,6 @@ setup:
|
|
| 6 |
run:
|
| 7 |
uvicorn src.main:app --reload --port 6006
|
| 8 |
|
| 9 |
-
run-ui:
|
| 10 |
-
python app.py
|
| 11 |
-
|
| 12 |
test:
|
| 13 |
pytest tests/
|
| 14 |
|
|
|
|
| 6 |
run:
|
| 7 |
uvicorn src.main:app --reload --port 6006
|
| 8 |
|
|
|
|
|
|
|
|
|
|
| 9 |
test:
|
| 10 |
pytest tests/
|
| 11 |
|
PHASE_2_DEVELOPMENT.md
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Phase 2: Personalization & React UI Migration
|
| 2 |
+
|
| 3 |
+
**Date:** January 2026
|
| 4 |
+
**Status:** ✅ Complete & Deployed
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## Overview
|
| 9 |
+
|
| 10 |
+
This phase shifted the project from a basic semantic book recommender to an **intelligent, personalized discovery platform** with a modern React frontend. The vision evolved from marketplace/swap features to a focused **recommendation engine grounded in user preferences and persona-driven insights**.
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## Phase Vision & Direction
|
| 15 |
+
|
| 16 |
+
### Initial Pivot (from conversation)
|
| 17 |
+
- **Original concept:** Second-hand book marketplace/swap platform
|
| 18 |
+
- **User feedback:** Focus on recommendation engine first, then expand
|
| 19 |
+
- **Final direction:** Keep it recommendation-only with two new pillars:
|
| 20 |
+
1. **Favorites** → persistent user library tracking
|
| 21 |
+
2. **Personalized Highlights** → AI-generated selling points based on user taste
|
| 22 |
+
|
| 23 |
+
### Core Philosophy
|
| 24 |
+
> "Books that understand you. Recommendations grounded in what you love."
|
| 25 |
+
|
| 26 |
+
The system learns from your reading preferences and surfaces books that match both the search query AND your unique taste profile.
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## What Was Built
|
| 31 |
+
|
| 32 |
+
### 1. **Backend Personalization Layer** (`src/`)
|
| 33 |
+
|
| 34 |
+
#### A. User Favorites Storage
|
| 35 |
+
- **File:** `src/user/profile_store.py`
|
| 36 |
+
- **Mechanism:** JSON-based persistence (`data/user_profiles.json`)
|
| 37 |
+
- **Features:**
|
| 38 |
+
- `add_favorite(user_id, isbn)` → idempotent add + deduplicate
|
| 39 |
+
- `list_favorites(user_id)` → retrieve user's library
|
| 40 |
+
- Works with any user_id (default: "local" for single-user dev)
|
| 41 |
+
|
| 42 |
+
#### B. User Persona Aggregation
|
| 43 |
+
- **File:** `src/marketing/persona.py`
|
| 44 |
+
- **Input:** List of favorite ISBNs + book metadata DataFrame
|
| 45 |
+
- **Output:** `{ summary, top_authors[], top_categories[] }`
|
| 46 |
+
- **Algorithm:**
|
| 47 |
+
1. Fetch metadata for all favorited books
|
| 48 |
+
2. Extract top 3 authors (by frequency)
|
| 49 |
+
3. Extract top 3 categories
|
| 50 |
+
4. Generate natural language summary combining signals
|
| 51 |
+
- Example: *"您钟爱悬疑与科幻,偏好国际视野的作品。"* (You love mystery & sci-fi, prefer international perspectives)
|
| 52 |
+
|
| 53 |
+
#### C. Personalized Highlights Generator
|
| 54 |
+
- **File:** `src/marketing/highlights.py`
|
| 55 |
+
- **Input:** ISBN + user persona + book metadata
|
| 56 |
+
- **Output:** `{ title, authors, category, highlights[], persona_summary }`
|
| 57 |
+
- **Generation Strategy:**
|
| 58 |
+
- Match persona themes to book content (author, category, description)
|
| 59 |
+
- Extract 3-5 contextual selling points
|
| 60 |
+
- Combine rule-based matching + description parsing
|
| 61 |
+
- Example output:
|
| 62 |
+
```
|
| 63 |
+
- 作者获国际奖项,契合您对国际视野的热爱
|
| 64 |
+
- 悬疑与科幻的完美融合,正是您的最爱组合
|
| 65 |
+
- 情节紧凑,适合您快节奏阅读的偏好
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
### 2. **FastAPI Backend Integration** (`src/main.py`)
|
| 69 |
+
|
| 70 |
+
**Three New Endpoints:**
|
| 71 |
+
|
| 72 |
+
```python
|
| 73 |
+
POST /favorites/add
|
| 74 |
+
Request: { user_id: str, isbn: str }
|
| 75 |
+
Response: { status: "ok", favorites_count: int }
|
| 76 |
+
|
| 77 |
+
GET /user/{user_id}/persona
|
| 78 |
+
Response: { user_id, favorites: [], persona: {...} }
|
| 79 |
+
|
| 80 |
+
POST /marketing/highlights
|
| 81 |
+
Request: { isbn: str, user_id?: str }
|
| 82 |
+
Response: { persona, highlights: [], meta: {...} }
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
**CORS Support:**
|
| 86 |
+
- Enabled for localhost:5173 (React dev), 3000 (alt dev), 8080
|
| 87 |
+
- Allows frontend to access backend without restrictions
|
| 88 |
+
|
| 89 |
+
---
|
| 90 |
+
|
| 91 |
+
### 3. **Modern React UI** (`web/`)
|
| 92 |
+
|
| 93 |
+
#### Architecture
|
| 94 |
+
- **Build Tool:** Vite (ultra-fast dev server, ~200ms startup)
|
| 95 |
+
- **Styling:** Tailwind CSS (CDN-based, no build required)
|
| 96 |
+
- **Icons:** lucide-react (modern SVG icons)
|
| 97 |
+
- **State Management:** React Hooks (useState only, no Redux)
|
| 98 |
+
|
| 99 |
+
#### Design: "纸间留白" (Paper Shelf)
|
| 100 |
+
A literary, minimalist aesthetic inspired by:
|
| 101 |
+
- Japanese minimalism (留白 = leaving white space)
|
| 102 |
+
- Second-hand bookstore vibes
|
| 103 |
+
- Serif typography (font-serif)
|
| 104 |
+
- Muted earth tones: `#b392ac` (mauve), `#f4acb7` (peach), `#faf9f6` (cream)
|
| 105 |
+
|
| 106 |
+
#### Core Features
|
| 107 |
+
|
| 108 |
+
**1. Discovery Tab (Default View)**
|
| 109 |
+
```
|
| 110 |
+
┌─────────────────────────────────┐
|
| 111 |
+
│ 纸间留白 │ Header + toggle "私人书斋"
|
| 112 |
+
├─────────────────────────────────┤
|
| 113 |
+
│ 墨色余温·灵魂契合 (if favorites) │ Smart carousel of alma-mate books
|
| 114 |
+
├─────────────────────────────────┤
|
| 115 |
+
│ [Search] [Category▼] [Mood▼] │ Semantic search + filters
|
| 116 |
+
│ 开启发现之旅 (Start Discovery) │
|
| 117 |
+
├─────────────────────────────────┤
|
| 118 |
+
│ [Book 1] [Book 2] [Book 3] ... │ 5-column responsive grid
|
| 119 |
+
│ (hover shows ai-generated hint) │
|
| 120 |
+
└─────────────────────────────────┘
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
**2. Book Detail Modal**
|
| 124 |
+
```
|
| 125 |
+
┌─────────────────────────────────┐
|
| 126 |
+
│ [Close] │
|
| 127 |
+
├─────────���────┬──────────────────┤
|
| 128 |
+
│ Cover │ Title │
|
| 129 |
+
│ ISBN │ Highlights │
|
| 130 |
+
│ Score ★★★★★ │ Description │
|
| 131 |
+
│ │ Chat Interface │
|
| 132 |
+
│ │ [Add to Library] │
|
| 133 |
+
└──────────────┴──────────────────┘
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
**3. Private Library ("私人书斋")**
|
| 137 |
+
- Toggle view to see only favorited books
|
| 138 |
+
- Shows reading statistics (mood distribution)
|
| 139 |
+
- Same gallery grid + detail modal
|
| 140 |
+
|
| 141 |
+
**4. Chat Interface (in modal)**
|
| 142 |
+
- Suggested questions tied to book context
|
| 143 |
+
- User messages vs AI responses styled differently
|
| 144 |
+
- AI grounded to book metadata (not LLM-based yet)
|
| 145 |
+
|
| 146 |
+
#### API Integration
|
| 147 |
+
All four key flows wired to backend:
|
| 148 |
+
|
| 149 |
+
```javascript
|
| 150 |
+
// Search → Recommendation
|
| 151 |
+
startDiscovery() → recommend(query, category, tone)
|
| 152 |
+
|
| 153 |
+
// Select book → Load highlights
|
| 154 |
+
openBook(book) → getHighlights(isbn)
|
| 155 |
+
|
| 156 |
+
// Add to collection
|
| 157 |
+
toggleCollect(book) → addFavorite(isbn)
|
| 158 |
+
|
| 159 |
+
// (Future) Refresh persona
|
| 160 |
+
persona = getPersona(userId)
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
## End-to-End Flow
|
| 166 |
+
|
| 167 |
+
### User Journey: "Discovery to Collection"
|
| 168 |
+
|
| 169 |
+
```
|
| 170 |
+
1. User enters search query + filters
|
| 171 |
+
↓
|
| 172 |
+
2. startDiscovery() calls POST /recommend
|
| 173 |
+
→ FastAPI semantic search + tone filtering
|
| 174 |
+
→ Returns top N books with thumbnails
|
| 175 |
+
↓
|
| 176 |
+
3. Books render in grid (hover shows AI hint)
|
| 177 |
+
↓
|
| 178 |
+
4. User clicks book → openBook()
|
| 179 |
+
→ Calls POST /marketing/highlights
|
| 180 |
+
→ Gets persona + 3-5 personalized selling points
|
| 181 |
+
→ Modal shows all details + chat
|
| 182 |
+
↓
|
| 183 |
+
5. User clicks "加入藏书馆" (Add to Collection)
|
| 184 |
+
→ Calls POST /favorites/add
|
| 185 |
+
→ Updates myCollection state
|
| 186 |
+
→ Next search shows "灵魂契合" carousel (matched books)
|
| 187 |
+
↓
|
| 188 |
+
6. User clicks "私人书斋" to view collection
|
| 189 |
+
→ Filters books to only favorites
|
| 190 |
+
→ Shows reading persona stats
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
---
|
| 194 |
+
|
| 195 |
+
## Technical Decisions
|
| 196 |
+
|
| 197 |
+
### Why JSON for Favorites (not SQLite)?
|
| 198 |
+
- **Rationale:** Single-user dev focus, rapid iteration
|
| 199 |
+
- **Trade-off:** 11k books × metadata in one file = acceptable overhead
|
| 200 |
+
- **Future:** Easy migration to PostgreSQL when scaling to multi-user
|
| 201 |
+
|
| 202 |
+
### Why No LLM for Highlights?
|
| 203 |
+
- **Rationale:** Keep system lightweight, deterministic, fast
|
| 204 |
+
- **Method:** Rule-based persona matching (Top-3 authors/categories)
|
| 205 |
+
- **Future:** Could upgrade to LLM refinement (e.g., GPT for polish)
|
| 206 |
+
|
| 207 |
+
### Why React + Vite?
|
| 208 |
+
- **Rationale:**
|
| 209 |
+
- React needed for custom UX and production-grade interface
|
| 210 |
+
- Vite super fast (no webpack pain)
|
| 211 |
+
- Tailwind CSS for modern styling
|
| 212 |
+
- **Architecture:** React frontend (port 5173) + FastAPI backend (port 6006/8000)
|
| 213 |
+
|
| 214 |
+
### Why Persona from Favorites (not search history)?
|
| 215 |
+
- **Rationale:** User intent explicit in favorites, not implicit in queries
|
| 216 |
+
- **Semantics:** "Add to collection" = explicit preference signal
|
| 217 |
+
- **Advantage:** Works offline, no tracking/privacy concerns
|
| 218 |
+
|
| 219 |
+
---
|
| 220 |
+
|
| 221 |
+
## Architecture Diagram
|
| 222 |
+
|
| 223 |
+
```
|
| 224 |
+
┌──────────────────────────────────────────────────────┐
|
| 225 |
+
│ FRONTEND (React) │
|
| 226 |
+
│ web/ → Vite dev server (localhost:5173) │
|
| 227 |
+
│ ┌────────────────────────────────────────────────┐ │
|
| 228 |
+
│ │ App.jsx │ │
|
| 229 |
+
│ │ - SearchBar (query, category, mood) │ │
|
| 230 |
+
│ │ - Gallery (books grid) │ │
|
| 231 |
+
│ │ - DetailModal (title, highlights, chat) │ │
|
| 232 |
+
│ │ - MyCollection (favorites view) │ │
|
| 233 |
+
│ └────────────────────────────────────────────────┘ │
|
| 234 |
+
│ api.js → Fetch wrappers (recommend, highlights...) │
|
| 235 |
+
└──────────────────────────────────────────────────────┘
|
| 236 |
+
↓
|
| 237 |
+
HTTP/CORS
|
| 238 |
+
↓
|
| 239 |
+
┌──────────────────────────────────────────────────────┐
|
| 240 |
+
│ BACKEND (FastAPI) │
|
| 241 |
+
│ src/main.py → uvicorn (localhost:6006) │
|
| 242 |
+
│ ┌────────────────────────────────────────────────┐ │
|
| 243 |
+
│ │ GET /health │ │
|
| 244 |
+
│ │ POST /recommend (query, category, tone) │ │
|
| 245 |
+
│ │ GET /categories, /tones │ │
|
| 246 |
+
│ │ ┌──────────────────────────────────────────┐ │ │
|
| 247 |
+
│ │ │ NEW: POST /favorites/add │ │ │
|
| 248 |
+
│ │ │ NEW: GET /user/{id}/persona │ │ │
|
| 249 |
+
│ │ │ NEW: POST /marketing/highlights │ │ │
|
| 250 |
+
│ │ └──────────────────────────────────────────┘ │ │
|
| 251 |
+
│ └────────────────────────────────────────────────┘ │
|
| 252 |
+
└──────────────────────────────────────────────────────┘
|
| 253 |
+
↓ ↓
|
| 254 |
+
┌─────────────┐ ┌──────────────────┐
|
| 255 |
+
│ ChromaDB │ │ User Profiles │
|
| 256 |
+
│ (11k docs) │ │ (JSON file) │
|
| 257 |
+
│ ↓ │ │ ↓ │
|
| 258 |
+
│ Vector │ │ Favorites + │
|
| 259 |
+
│ Embeddings │ │ Persona │
|
| 260 |
+
└─────────────┘ └──────────────────┘
|
| 261 |
+
↓
|
| 262 |
+
┌─────────────────────────────────┐
|
| 263 |
+
│ Books Metadata (CSV) │
|
| 264 |
+
│ - title, authors, description │
|
| 265 |
+
│ - isbn, category, rating │
|
| 266 |
+
│ - emotion scores (joy/sad/etc) │
|
| 267 |
+
└─────────────────────────────────┘
|
| 268 |
+
```
|
| 269 |
+
|
| 270 |
+
---
|
| 271 |
+
|
| 272 |
+
## Key Data Models
|
| 273 |
+
|
| 274 |
+
### User Profile (JSON)
|
| 275 |
+
```json
|
| 276 |
+
{
|
| 277 |
+
"local": {
|
| 278 |
+
"favorites": [
|
| 279 |
+
{ "isbn": "9780451524935", "title": "1984", "added_at": "2026-01-06" },
|
| 280 |
+
{ "isbn": "9780061120084", "title": "To Kill a Mockingbird", "added_at": "2026-01-06" }
|
| 281 |
+
]
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
```
|
| 285 |
+
|
| 286 |
+
### Book Recommendation Response
|
| 287 |
+
```json
|
| 288 |
+
{
|
| 289 |
+
"recommendations": [
|
| 290 |
+
{
|
| 291 |
+
"isbn": "9780451524935",
|
| 292 |
+
"title": "1984",
|
| 293 |
+
"authors": "George Orwell",
|
| 294 |
+
"description": "A dystopian novel...",
|
| 295 |
+
"thumbnail": "https://covers.openlibrary.org/...",
|
| 296 |
+
"caption": "(auto-generated short hint)"
|
| 297 |
+
}
|
| 298 |
+
]
|
| 299 |
+
}
|
| 300 |
+
```
|
| 301 |
+
|
| 302 |
+
### Highlights Response
|
| 303 |
+
```json
|
| 304 |
+
{
|
| 305 |
+
"persona": {
|
| 306 |
+
"summary": "您钟爱悬疑与科幻,偏好国际视野的作品。",
|
| 307 |
+
"top_authors": ["Agatha Christie", "Isaac Asimov"],
|
| 308 |
+
"top_categories": ["Mystery", "Science Fiction"]
|
| 309 |
+
},
|
| 310 |
+
"highlights": [
|
| 311 |
+
"国际推理大师之作,契合您的悬疑偏好",
|
| 312 |
+
"心理扭转的情节设计,适合您快节奏阅读",
|
| 313 |
+
"深层人性反思,引发思考"
|
| 314 |
+
],
|
| 315 |
+
"meta": {
|
| 316 |
+
"title": "And Then There Were None",
|
| 317 |
+
"authors": "Agatha Christie",
|
| 318 |
+
"category": "Mystery",
|
| 319 |
+
"description": "..."
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
```
|
| 323 |
+
|
| 324 |
+
---
|
| 325 |
+
|
| 326 |
+
## Running the System
|
| 327 |
+
|
| 328 |
+
### Development Mode (3 services)
|
| 329 |
+
|
| 330 |
+
**Terminal 1: FastAPI Backend**
|
| 331 |
+
```bash
|
| 332 |
+
cd /Users/ymlin/Downloads/003-Study/138-Projects/book-rec-with-LLMs
|
| 333 |
+
make run
|
| 334 |
+
# Starts on http://localhost:6006
|
| 335 |
+
# Loads 11k books into ChromaDB
|
| 336 |
+
# Initializes metrics, routes
|
| 337 |
+
```
|
| 338 |
+
|
| 339 |
+
**Terminal 2: React Frontend**
|
| 340 |
+
```bash
|
| 341 |
+
cd web
|
| 342 |
+
npm run dev
|
| 343 |
+
# Starts on http://localhost:5173
|
| 344 |
+
# Hot reload on file changes
|
| 345 |
+
# Connect to http://localhost:6006 backend
|
| 346 |
+
```
|
| 347 |
+
|
| 348 |
+
### Production Workflow
|
| 349 |
+
- React builds with `npm run build` → static files
|
| 350 |
+
- FastAPI serves as single backend
|
| 351 |
+
- Deploy as Docker containers (see DEPLOYMENT.md)
|
| 352 |
+
|
| 353 |
+
---
|
| 354 |
+
|
| 355 |
+
## Testing the Features
|
| 356 |
+
|
| 357 |
+
### 1. Test Semantic Search
|
| 358 |
+
```
|
| 359 |
+
Input: "悬疑推理小说,节奏快"
|
| 360 |
+
Expected: Agatha Christie, Sherlock Holmes, modern thrillers
|
| 361 |
+
```
|
| 362 |
+
|
| 363 |
+
### 2. Test Favorites → Persona
|
| 364 |
+
```
|
| 365 |
+
1. Add 5 books to collection (mix of genres)
|
| 366 |
+
2. Click a new book
|
| 367 |
+
3. Check highlights mention added books' authors/categories
|
| 368 |
+
✓ Persona should reflect your choices
|
| 369 |
+
```
|
| 370 |
+
|
| 371 |
+
### 3. Test Persona-Based Highlights
|
| 372 |
+
```
|
| 373 |
+
If you favorite: [Sci-Fi, Mystery, Literary]
|
| 374 |
+
Then recommend: Horror book X
|
| 375 |
+
Expected highlight: "虽不在您常读类型,但情节深度与科幻的想象力结合..."
|
| 376 |
+
(Acknowledges taste + bridges to new territory)
|
| 377 |
+
```
|
| 378 |
+
|
| 379 |
+
---
|
| 380 |
+
|
| 381 |
+
## Future Enhancements
|
| 382 |
+
|
| 383 |
+
### Phase 3: Recommendations (Backlog)
|
| 384 |
+
|
| 385 |
+
**1. LLM-Powered Highlights**
|
| 386 |
+
- Use Claude/GPT to refine rule-based highlights
|
| 387 |
+
- Natural language refinement (currently ~70% rule-based quality)
|
| 388 |
+
- Cache per (user_id, isbn) pair for speed
|
| 389 |
+
|
| 390 |
+
**2. Emotional Resonance Scoring**
|
| 391 |
+
- Leverage emotion embeddings (joy/sadness/fear/anger/surprise) in metadata
|
| 392 |
+
- Recommend books matching user's current mood signal
|
| 393 |
+
- "What are you feeling today?" filter
|
| 394 |
+
|
| 395 |
+
**3. Multi-User Accounts**
|
| 396 |
+
- Migrate from JSON to SQLite/PostgreSQL
|
| 397 |
+
- User authentication (OAuth)
|
| 398 |
+
- Social features (share collections, compare tastes)
|
| 399 |
+
|
| 400 |
+
**4. Advanced Search**
|
| 401 |
+
- Author-to-author recommendations ("If you like X, try Y's style")
|
| 402 |
+
- Time-based recommendations ("What to read this season?")
|
| 403 |
+
- Combination search (mood + timeframe + word-count)
|
| 404 |
+
|
| 405 |
+
**5. Analytics Dashboard**
|
| 406 |
+
- Show user: "You've read 15 books in the mystery genre"
|
| 407 |
+
- Predict next book based on reading history
|
| 408 |
+
- Genre comfort zone vs stretch zones
|
| 409 |
+
|
| 410 |
+
---
|
| 411 |
+
|
| 412 |
+
## Phase Reflection
|
| 413 |
+
|
| 414 |
+
### What Worked Well
|
| 415 |
+
✅ **Modular backend design** → easy to add /highlights, /persona endpoints
|
| 416 |
+
✅ **React UI responsiveness** → users see results instantly
|
| 417 |
+
✅ **JSON-first approach** → no DB setup friction, iterate fast
|
| 418 |
+
✅ **API-driven architecture** → React frontend with FastAPI backend
|
| 419 |
+
✅ **Persona concept** → users feel "understood" by the system
|
| 420 |
+
|
| 421 |
+
### Challenges Overcome
|
| 422 |
+
🔧 **Port configuration** (React:5173 vs FastAPI:6006/8000) → Makefile organization
|
| 423 |
+
🔧 **CORS issues** (frontend can't reach backend) → Added CORSMiddleware
|
| 424 |
+
🔧 **Image loading** (external URLs) → Runtime fetching + local fallback
|
| 425 |
+
🔧 **Timeout errors** (cold startup > 10s) → Increased client timeouts, optimized startup
|
| 426 |
+
|
| 427 |
+
### Design Philosophy Validated
|
| 428 |
+
The shift from "marketplace" → "recommendation + personalization" was right because:
|
| 429 |
+
1. **Clear unique value:** Persona-aware recommendations don't exist in typical bookstores
|
| 430 |
+
2. **Tight scope:** Focused on one thing (smart discovery) vs scattered marketplace features
|
| 431 |
+
3. **User empathy:** People want to be understood, not just transact
|
| 432 |
+
|
| 433 |
+
---
|
| 434 |
+
|
| 435 |
+
## Code Structure Summary
|
| 436 |
+
|
| 437 |
+
```
|
| 438 |
+
book-rec-with-LLMs/
|
| 439 |
+
├── src/
|
| 440 |
+
│ ├── main.py # FastAPI app + 3 new endpoints
|
| 441 |
+
│ ├── recommender.py # Semantic search core
|
| 442 |
+
│ ├── vector_db.py # ChromaDB wrapper
|
| 443 |
+
│ ├── cache.py # Image caching
|
| 444 |
+
│ ├── user/
|
| 445 |
+
│ │ └── profile_store.py # ✨ NEW: Favorites JSON storage
|
| 446 |
+
│ └── marketing/
|
| 447 |
+
│ ├── persona.py # ✨ NEW: Persona aggregation
|
| 448 |
+
│ ├── highlights.py # ✨ NEW: Highlight generation
|
| 449 |
+
│ └── guardrails.py # Safety checks (stub)
|
| 450 |
+
├── web/ # ✨ NEW: React Vite app
|
| 451 |
+
│ ├── src/
|
| 452 |
+
│ │ ├── App.jsx # Main component + state
|
| 453 |
+
│ │ ├── api.js # Fetch wrappers
|
| 454 |
+
│ │ └── main.jsx # Entry point
|
| 455 |
+
│ ├── index.html # HTML + Tailwind CDN
|
| 456 |
+
│ └── package.json # Dependencies
|
| 457 |
+
├── Makefile # Commands
|
| 458 |
+
├── requirements.txt # Python deps
|
| 459 |
+
└── data/
|
| 460 |
+
├── books_processed.csv # Metadata + review highlights
|
| 461 |
+
└── user_profiles.json # User data
|
| 462 |
+
```
|
| 463 |
+
|
| 464 |
+
---
|
| 465 |
+
|
| 466 |
+
## Commit Message
|
| 467 |
+
```
|
| 468 |
+
feat: add React UI and backend personalization features
|
| 469 |
+
|
| 470 |
+
- Create modern React UI (web/) with 纸间留白 design
|
| 471 |
+
* Semantic search + favorites + detail modal
|
| 472 |
+
* Tailwind CSS + lucide-react
|
| 473 |
+
* Vite dev server on port 5173
|
| 474 |
+
|
| 475 |
+
- Implement user personalization:
|
| 476 |
+
* src/user/profile_store.py: JSON favorites
|
| 477 |
+
* src/marketing/persona.py: User taste aggregation
|
| 478 |
+
* src/marketing/highlights.py: Persona-aware selling points
|
| 479 |
+
* 3 new API endpoints in FastAPI
|
| 480 |
+
|
| 481 |
+
- Add CORS support, update timeouts, improve infrastructure
|
| 482 |
+
```
|
| 483 |
+
|
| 484 |
+
---
|
| 485 |
+
|
| 486 |
+
## How to Continue
|
| 487 |
+
|
| 488 |
+
### If you want to test now:
|
| 489 |
+
1. `make run` (starts backend)
|
| 490 |
+
2. `cd web && npm run dev` (starts React UI)
|
| 491 |
+
3. Visit http://localhost:5173
|
| 492 |
+
4. Search for a book → click results → "加入藏书馆" → see persona highlights
|
| 493 |
+
|
| 494 |
+
### If you want to refine:
|
| 495 |
+
- Adjust persona algorithm in `src/marketing/persona.py`
|
| 496 |
+
- Tweak UI colors/layout in `web/src/App.jsx`
|
| 497 |
+
- Add more rules to highlights in `src/marketing/highlights.py`
|
| 498 |
+
|
| 499 |
+
### If you want to scale:
|
| 500 |
+
- Migrate to PostgreSQL (users table + favorites relationship)
|
| 501 |
+
- Add user auth (FastAPI auth middleware)
|
| 502 |
+
- Deploy with Docker + cloud (see DEPLOYMENT.md)
|
| 503 |
+
|
| 504 |
+
---
|
| 505 |
+
|
| 506 |
+
**Status:** ✅ **Ready to Deploy**
|
| 507 |
+
|
| 508 |
+
Next phase can focus on: multi-user support, LLM refinement, analytics, or social features.
|
| 509 |
+
|
README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
license: mit
|
| 3 |
title: Semantic-Based Book Recommendation Framework
|
| 4 |
sdk: docker
|
| 5 |
-
app_port:
|
| 6 |
---
|
| 7 |
|
| 8 |
# Semantic-Based Book Recommendation Framework using Large Language Model Embeddings
|
|
@@ -57,10 +57,10 @@ This project presents a comprehensive, multi-modal recommendation and e-commerce
|
|
| 57 |
|
| 58 |
## System Architecture
|
| 59 |
|
| 60 |
-
The project follows a
|
| 61 |
|
| 62 |
-
* **Frontend**:
|
| 63 |
-
* **Backend API**: FastAPI service
|
| 64 |
* **Data Layer**:
|
| 65 |
* **Amazon Books Dataset**: 200,000+ records processed via custom ETL pipelines.
|
| 66 |
* **Vector Store**: ChromaDB for embedding storage and similarity search.
|
|
@@ -70,11 +70,12 @@ The project follows a microservices-inspired architecture:
|
|
| 70 |
|
| 71 |
### Prerequisites
|
| 72 |
* Python 3.10+
|
| 73 |
-
*
|
|
|
|
| 74 |
|
| 75 |
### Deployment
|
| 76 |
|
| 77 |
-
**Option 1:
|
| 78 |
|
| 79 |
1. **Clone the repository**:
|
| 80 |
```bash
|
|
@@ -82,10 +83,9 @@ The project follows a microservices-inspired architecture:
|
|
| 82 |
cd book-rec-with-LLMs
|
| 83 |
```
|
| 84 |
|
| 85 |
-
2. **Install dependencies**:
|
| 86 |
```bash
|
| 87 |
-
|
| 88 |
-
# or: pip install -r requirements.txt
|
| 89 |
```
|
| 90 |
|
| 91 |
3. **Start API Server** (Terminal 1):
|
|
@@ -94,14 +94,16 @@ The project follows a microservices-inspired architecture:
|
|
| 94 |
# Starts FastAPI on http://localhost:6006
|
| 95 |
```
|
| 96 |
|
| 97 |
-
4. **
|
| 98 |
```bash
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
| 101 |
```
|
| 102 |
|
| 103 |
5. **Access the Interface**:
|
| 104 |
-
Navigate to `http://localhost:
|
| 105 |
|
| 106 |
**Option 2: Docker Deployment**
|
| 107 |
|
|
@@ -111,7 +113,8 @@ The project follows a microservices-inspired architecture:
|
|
| 111 |
```
|
| 112 |
|
| 113 |
2. **Access the Interface**:
|
| 114 |
-
|
|
|
|
| 115 |
|
| 116 |
**Notes:**
|
| 117 |
- Redis is optional; caching will be disabled if Redis is unavailable
|
|
@@ -168,7 +171,7 @@ To deploy the system locally, execute the following commands:
|
|
| 168 |
|
| 169 |
The services will be available at:
|
| 170 |
- **API Documentation**: `http://localhost:8000/docs`
|
| 171 |
-
- **
|
| 172 |
|
| 173 |
## 7. References
|
| 174 |
|
|
|
|
| 2 |
license: mit
|
| 3 |
title: Semantic-Based Book Recommendation Framework
|
| 4 |
sdk: docker
|
| 5 |
+
app_port: 8000
|
| 6 |
---
|
| 7 |
|
| 8 |
# Semantic-Based Book Recommendation Framework using Large Language Model Embeddings
|
|
|
|
| 57 |
|
| 58 |
## System Architecture
|
| 59 |
|
| 60 |
+
The project follows a modern full-stack architecture:
|
| 61 |
|
| 62 |
+
* **Frontend**: React 18 + Vite, providing an intuitive book search and recommendation interface.
|
| 63 |
+
* **Backend API**: FastAPI service for recommendation logic and data retrieval.
|
| 64 |
* **Data Layer**:
|
| 65 |
* **Amazon Books Dataset**: 200,000+ records processed via custom ETL pipelines.
|
| 66 |
* **Vector Store**: ChromaDB for embedding storage and similarity search.
|
|
|
|
| 70 |
|
| 71 |
### Prerequisites
|
| 72 |
* Python 3.10+
|
| 73 |
+
* Node.js 18+ and npm/yarn
|
| 74 |
+
* Docker and Docker Compose (optional)
|
| 75 |
|
| 76 |
### Deployment
|
| 77 |
|
| 78 |
+
**Option 1: Development Mode**
|
| 79 |
|
| 80 |
1. **Clone the repository**:
|
| 81 |
```bash
|
|
|
|
| 83 |
cd book-rec-with-LLMs
|
| 84 |
```
|
| 85 |
|
| 86 |
+
2. **Install backend dependencies**:
|
| 87 |
```bash
|
| 88 |
+
pip install -r requirements.txt
|
|
|
|
| 89 |
```
|
| 90 |
|
| 91 |
3. **Start API Server** (Terminal 1):
|
|
|
|
| 94 |
# Starts FastAPI on http://localhost:6006
|
| 95 |
```
|
| 96 |
|
| 97 |
+
4. **Install and start frontend** (Terminal 2):
|
| 98 |
```bash
|
| 99 |
+
cd web
|
| 100 |
+
npm install
|
| 101 |
+
npm run dev
|
| 102 |
+
# Starts React app on http://localhost:5173
|
| 103 |
```
|
| 104 |
|
| 105 |
5. **Access the Interface**:
|
| 106 |
+
Navigate to `http://localhost:5173` in a web browser.
|
| 107 |
|
| 108 |
**Option 2: Docker Deployment**
|
| 109 |
|
|
|
|
| 113 |
```
|
| 114 |
|
| 115 |
2. **Access the Interface**:
|
| 116 |
+
API will be available at `http://localhost:8000`
|
| 117 |
+
Frontend development server should be started separately (see Option 1, step 4)
|
| 118 |
|
| 119 |
**Notes:**
|
| 120 |
- Redis is optional; caching will be disabled if Redis is unavailable
|
|
|
|
| 171 |
|
| 172 |
The services will be available at:
|
| 173 |
- **API Documentation**: `http://localhost:8000/docs`
|
| 174 |
+
- **Frontend**: Start separately with `npm run dev` (see above)
|
| 175 |
|
| 176 |
## 7. References
|
| 177 |
|
REVIEW_HIGHLIGHTS.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Review Highlights Feature
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
Added semantic sentence extraction to display representative reader reviews for each book. This feature enhances book discovery by showcasing authentic reader voices.
|
| 6 |
+
|
| 7 |
+
## Implementation
|
| 8 |
+
|
| 9 |
+
### 1. Data Generation (Server-side)
|
| 10 |
+
|
| 11 |
+
**Script**: `scripts/extract_review_sentences.py`
|
| 12 |
+
|
| 13 |
+
**Process**:
|
| 14 |
+
- Splits book descriptions into sentences using regex
|
| 15 |
+
- Uses `sentence-transformers/all-MiniLM-L6-v2` for sentence embeddings
|
| 16 |
+
- Clusters similar sentences via cosine similarity (threshold: 0.8)
|
| 17 |
+
- Extracts representative sentences from each cluster (top 5 per book)
|
| 18 |
+
- Stores as semicolon-separated `review_highlights` column in CSV
|
| 19 |
+
|
| 20 |
+
**Execution**:
|
| 21 |
+
```bash
|
| 22 |
+
# Run in container with GPU
|
| 23 |
+
export HF_ENDPOINT=https://hf-mirror.com
|
| 24 |
+
python scripts/extract_review_sentences.py \
|
| 25 |
+
--input data/books_processed.csv \
|
| 26 |
+
--output data/books_processed.csv \
|
| 27 |
+
--top-n 5 \
|
| 28 |
+
--similarity-threshold 0.8 \
|
| 29 |
+
--device 0 \
|
| 30 |
+
--batch-size 128
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
**Performance**: ~17 minutes for 222k books on GPU (211 it/s)
|
| 34 |
+
|
| 35 |
+
### 2. Backend Integration
|
| 36 |
+
|
| 37 |
+
**Files Modified**:
|
| 38 |
+
- `src/recommender.py`: Parse `review_highlights` from CSV, split by semicolon
|
| 39 |
+
- `src/main.py`: Add `review_highlights: List[str]` to `BookResponse` model
|
| 40 |
+
|
| 41 |
+
**Code**:
|
| 42 |
+
```python
|
| 43 |
+
# Parse review highlights from semicolon-separated string
|
| 44 |
+
highlights_raw = str(row.get("review_highlights", "")).strip()
|
| 45 |
+
review_highlights = [h.strip() for h in highlights_raw.split(";") if h.strip()]
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
### 3. Frontend Display
|
| 49 |
+
|
| 50 |
+
**File**: `web/src/App.jsx`
|
| 51 |
+
|
| 52 |
+
**Location**: Left column, bottom section (below Rating/Mood)
|
| 53 |
+
|
| 54 |
+
**Features**:
|
| 55 |
+
- Displays up to 3 representative sentences
|
| 56 |
+
- Bullet-point format with `-` prefix
|
| 57 |
+
- Complete sentences: `- "[sentence]"`
|
| 58 |
+
- Incomplete sentences: `- "...[sentence]"` (auto-detected via regex `/^[A-Z]/`)
|
| 59 |
+
- Styling: 10px italic gray text
|
| 60 |
+
|
| 61 |
+
**Layout**:
|
| 62 |
+
```jsx
|
| 63 |
+
{selectedBook.review_highlights && selectedBook.review_highlights.length > 0 && (
|
| 64 |
+
<div className="w-full mt-auto space-y-2 text-left">
|
| 65 |
+
{selectedBook.review_highlights.slice(0, 3).map((highlight, idx) => {
|
| 66 |
+
const isCompleteSentence = /^[A-Z]/.test(highlight.trim());
|
| 67 |
+
const prefix = isCompleteSentence ? '' : '...';
|
| 68 |
+
return (
|
| 69 |
+
<p key={idx} className="text-[10px] text-[#666] leading-relaxed italic pl-2">
|
| 70 |
+
- "{prefix}{highlight}"
|
| 71 |
+
</p>
|
| 72 |
+
);
|
| 73 |
+
})}
|
| 74 |
+
</div>
|
| 75 |
+
)}
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
## Related Changes
|
| 79 |
+
|
| 80 |
+
### Rating Display Enhancement
|
| 81 |
+
|
| 82 |
+
**Problem**: Hardcoded rating value of 4 stars for all books
|
| 83 |
+
|
| 84 |
+
**Solution**:
|
| 85 |
+
- Added `average_rating` field to backend API response
|
| 86 |
+
- Display format: `4.3` (1 decimal) + filled stars
|
| 87 |
+
- Moved rating display into AI highlight box (pink desc_block)
|
| 88 |
+
|
| 89 |
+
**Frontend mapping**:
|
| 90 |
+
```javascript
|
| 91 |
+
rating: r.average_rating || 0, // Keep float, no rounding
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
**Display**:
|
| 95 |
+
```jsx
|
| 96 |
+
<span>{selectedBook.rating ? selectedBook.rating.toFixed(1) : '0.0'}</span>
|
| 97 |
+
<div className="flex gap-0.5 text-[#f4acb7]">
|
| 98 |
+
{[1,2,3,4,5].map(i => <Star key={i} className={`w-3 h-3 ${i <= selectedBook.rating ? 'fill-current' : ''}`} />)}
|
| 99 |
+
</div>
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
### Layout Adjustments
|
| 103 |
+
|
| 104 |
+
- Grid ratio: 4:8 → 5:7 (more space for left column)
|
| 105 |
+
- Rating/Mood: Changed from vertical stack to consolidated display
|
| 106 |
+
- Rating moved into desc_block (AI highlight box)
|
| 107 |
+
- Review highlights positioned at bottom with `mt-auto`
|
| 108 |
+
|
| 109 |
+
## Data Schema
|
| 110 |
+
|
| 111 |
+
**CSV Column**: `review_highlights` (string, semicolon-separated)
|
| 112 |
+
|
| 113 |
+
**Example**:
|
| 114 |
+
```
|
| 115 |
+
"Having been brought up on the notion...;It transpires, some years ago...;This is a work full of wisdom..."
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
**API Response**:
|
| 119 |
+
```json
|
| 120 |
+
{
|
| 121 |
+
"review_highlights": [
|
| 122 |
+
"Having been brought up on the notion that Elizabeth Barrett Browning was the slighter poet...",
|
| 123 |
+
"It transpires, some years ago, Clarke hosted two hugely successful British television series...",
|
| 124 |
+
"This is a work full of wisdom and unusual perspectives."
|
| 125 |
+
],
|
| 126 |
+
"average_rating": 3.716216
|
| 127 |
+
}
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
## Notes
|
| 131 |
+
|
| 132 |
+
- Review highlights are pre-computed and stored in CSV (no runtime extraction)
|
| 133 |
+
- Data file `books_processed.csv` (~243MB) must be regenerated after container rebuild
|
| 134 |
+
- Use `scp` to transfer processed CSV back to local machine
|
| 135 |
+
- HuggingFace mirror (`HF_ENDPOINT`) required for model download in restricted networks
|
| 136 |
+
|
| 137 |
+
## Future Improvements
|
| 138 |
+
|
| 139 |
+
- Cache sentence embeddings to speed up re-generation
|
| 140 |
+
- Add sentiment analysis to highlights (positive/critical)
|
| 141 |
+
- Filter highlights by relevance to user query
|
| 142 |
+
- Display highlight source (verified purchase vs. regular review)
|
TAGS_AND_EMOTIONS.md
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Tags and Emotion Scoring
|
| 2 |
+
|
| 3 |
+
This document describes the tag generation and emotion scoring features added to enrich book metadata.
|
| 4 |
+
|
| 5 |
+
## Overview
|
| 6 |
+
|
| 7 |
+
- **Tags**: Keyword extraction from book descriptions using TF-IDF (5-8 terms per book)
|
| 8 |
+
- **Emotion Scores**: Five emotion dimensions (joy, sadness, fear, anger, surprise) computed via transformer model
|
| 9 |
+
|
| 10 |
+
## Data Generation
|
| 11 |
+
|
| 12 |
+
### 1. Tag Generation
|
| 13 |
+
|
| 14 |
+
Extracts thematic keywords from aggregated review text.
|
| 15 |
+
|
| 16 |
+
**Script**: `scripts/generate_tags.py`
|
| 17 |
+
|
| 18 |
+
**Usage**:
|
| 19 |
+
```bash
|
| 20 |
+
python scripts/generate_tags.py \
|
| 21 |
+
--input data/books_processed.csv \
|
| 22 |
+
--output data/books_processed.csv \
|
| 23 |
+
--top-n 8
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
**Algorithm**:
|
| 27 |
+
- TF-IDF vectorization (unigrams + bigrams)
|
| 28 |
+
- English stopwords + domain stoplist (e.g., "book", "author", "story")
|
| 29 |
+
- Top-N weighted terms per book
|
| 30 |
+
- Semicolon-joined storage in `tags` column
|
| 31 |
+
|
| 32 |
+
**Parameters**:
|
| 33 |
+
- `--top-n`: Max tags per book (default: 8)
|
| 34 |
+
- `--max-features`: TF-IDF vocabulary size (default: 60,000)
|
| 35 |
+
- `--min-df`: Minimum document frequency (default: 5)
|
| 36 |
+
- `--max-df`: Maximum document frequency ratio (default: 0.5)
|
| 37 |
+
|
| 38 |
+
### 2. Emotion Scoring
|
| 39 |
+
|
| 40 |
+
Computes emotion intensity scores from book descriptions.
|
| 41 |
+
|
| 42 |
+
**Script**: `scripts/generate_emotions.py`
|
| 43 |
+
|
| 44 |
+
**Model**: `j-hartmann/emotion-english-distilroberta-base`
|
| 45 |
+
|
| 46 |
+
**Usage**:
|
| 47 |
+
```bash
|
| 48 |
+
# CPU
|
| 49 |
+
python scripts/generate_emotions.py \
|
| 50 |
+
--input data/books_processed.csv \
|
| 51 |
+
--output data/books_processed.csv \
|
| 52 |
+
--batch-size 16
|
| 53 |
+
|
| 54 |
+
# Apple GPU (MPS)
|
| 55 |
+
python scripts/generate_emotions.py \
|
| 56 |
+
--input data/books_processed.csv \
|
| 57 |
+
--output data/books_processed.csv \
|
| 58 |
+
--batch-size 8 \
|
| 59 |
+
--device mps \
|
| 60 |
+
--checkpoint 2000 \
|
| 61 |
+
--resume
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
**Parameters**:
|
| 65 |
+
- `--batch-size`: Inference batch size (default: 16)
|
| 66 |
+
- `--device`: `mps` (Apple GPU), CUDA device id, or CPU (default)
|
| 67 |
+
- `--checkpoint`: Rows between checkpoint writes (default: 5000)
|
| 68 |
+
- `--resume`: Skip rows already scored (useful for resuming long runs)
|
| 69 |
+
- `--max-rows`: Limit processing to N rows (for testing)
|
| 70 |
+
|
| 71 |
+
**Output Columns**:
|
| 72 |
+
- `joy`: 0.0–1.0
|
| 73 |
+
- `sadness`: 0.0–1.0
|
| 74 |
+
- `fear`: 0.0–1.0
|
| 75 |
+
- `anger`: 0.0–1.0
|
| 76 |
+
- `surprise`: 0.0–1.0
|
| 77 |
+
|
| 78 |
+
**Performance**:
|
| 79 |
+
- ~1.1 it/s on Apple M-series GPU
|
| 80 |
+
- ~7 hours for 222k books (batch_size=8, MPS)
|
| 81 |
+
- One-time processing; results persist in CSV
|
| 82 |
+
|
| 83 |
+
## Data Schema
|
| 84 |
+
|
| 85 |
+
Updated `books_processed.csv` columns:
|
| 86 |
+
|
| 87 |
+
| Column | Type | Description |
|
| 88 |
+
|--------|------|-------------|
|
| 89 |
+
| `tags` | str | Semicolon-separated keywords (e.g., "irish;travel;humor") |
|
| 90 |
+
| `joy` | float | Joy emotion score (0.0–1.0) |
|
| 91 |
+
| `sadness` | float | Sadness emotion score (0.0–1.0) |
|
| 92 |
+
| `fear` | float | Fear emotion score (0.0–1.0) |
|
| 93 |
+
| `anger` | float | Anger emotion score (0.0–1.0) |
|
| 94 |
+
| `surprise` | float | Surprise emotion score (0.0–1.0) |
|
| 95 |
+
|
| 96 |
+
## API Integration
|
| 97 |
+
|
| 98 |
+
### Backend Changes
|
| 99 |
+
|
| 100 |
+
**File**: `src/recommender.py`
|
| 101 |
+
|
| 102 |
+
Added to `_format_results()`:
|
| 103 |
+
```python
|
| 104 |
+
# Parse tags
|
| 105 |
+
tags_raw = str(row.get("tags", "")).strip()
|
| 106 |
+
tags = [t.strip() for t in tags_raw.split(";") if t.strip()] if tags_raw else []
|
| 107 |
+
|
| 108 |
+
# Extract emotions
|
| 109 |
+
emotions = {
|
| 110 |
+
"joy": float(row.get("joy", 0.0)),
|
| 111 |
+
"sadness": float(row.get("sadness", 0.0)),
|
| 112 |
+
"fear": float(row.get("fear", 0.0)),
|
| 113 |
+
"anger": float(row.get("anger", 0.0)),
|
| 114 |
+
"surprise": float(row.get("surprise", 0.0)),
|
| 115 |
+
}
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
**File**: `src/main.py`
|
| 119 |
+
|
| 120 |
+
Updated Pydantic model:
|
| 121 |
+
```python
|
| 122 |
+
class BookResponse(BaseModel):
|
| 123 |
+
isbn: str
|
| 124 |
+
title: str
|
| 125 |
+
authors: str
|
| 126 |
+
description: str
|
| 127 |
+
thumbnail: str
|
| 128 |
+
caption: str
|
| 129 |
+
tags: List[str] = []
|
| 130 |
+
emotions: Dict[str, float] = {}
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
### API Response Example
|
| 134 |
+
|
| 135 |
+
```json
|
| 136 |
+
{
|
| 137 |
+
"recommendations": [
|
| 138 |
+
{
|
| 139 |
+
"isbn": "0001849883",
|
| 140 |
+
"title": "Bury My Bones But Keep My Words",
|
| 141 |
+
"authors": "Deborah Savage, Tony Fairman",
|
| 142 |
+
"tags": ["paulsen", "otters", "searches", "gary", "brian"],
|
| 143 |
+
"emotions": {
|
| 144 |
+
"joy": 0.020,
|
| 145 |
+
"sadness": 0.004,
|
| 146 |
+
"fear": 0.012,
|
| 147 |
+
"anger": 0.006,
|
| 148 |
+
"surprise": 0.086
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
]
|
| 152 |
+
}
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
## UI Display
|
| 156 |
+
|
| 157 |
+
### Search Results Grid
|
| 158 |
+
|
| 159 |
+
Each book card displays:
|
| 160 |
+
- **Dominant emotion label**: Emotion with highest score (bottom-right badge)
|
| 161 |
+
- Example: "joy", "sadness", "fear"
|
| 162 |
+
|
| 163 |
+
**Implementation** (`web/src/App.jsx`):
|
| 164 |
+
```jsx
|
| 165 |
+
{book.emotions && Object.keys(book.emotions).length > 0 ? (
|
| 166 |
+
<span className="text-[9px] bg-[#f8f9fa] border border-[#eee] px-1 text-[#999] capitalize">
|
| 167 |
+
{Object.entries(book.emotions).reduce((a, b) => a[1] > b[1] ? a : b)[0]}
|
| 168 |
+
</span>
|
| 169 |
+
) : (
|
| 170 |
+
<span className="text-[9px] bg-[#f8f9fa] border border-[#eee] px-1 text-[#999]">—</span>
|
| 171 |
+
)}
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
### Book Detail Modal
|
| 175 |
+
|
| 176 |
+
Two new sections:
|
| 177 |
+
|
| 178 |
+
**1. Key Themes**
|
| 179 |
+
- Displays all extracted tags as badges
|
| 180 |
+
- Shows "No themes found" if tags empty
|
| 181 |
+
|
| 182 |
+
**2. Emotional Tone**
|
| 183 |
+
- Five horizontal bars showing emotion scores
|
| 184 |
+
- Bar width = score percentage (0–100%)
|
| 185 |
+
- Format: `emotion_name | [bar] | percentage`
|
| 186 |
+
|
| 187 |
+
**Implementation** (`web/src/App.jsx`):
|
| 188 |
+
```jsx
|
| 189 |
+
<div className="space-y-2">
|
| 190 |
+
<h4>Emotional Tone</h4>
|
| 191 |
+
<div className="space-y-2 p-3 bg-[#faf9f6] border border-[#eee]">
|
| 192 |
+
{selectedBook.emotions && Object.entries(selectedBook.emotions).map(([emotion, score]) => (
|
| 193 |
+
<div key={emotion} className="flex items-center gap-2">
|
| 194 |
+
<span className="text-[9px] font-bold text-gray-500 w-16 capitalize">{emotion}</span>
|
| 195 |
+
<div className="flex-grow bg-white border border-[#eee] h-2 relative overflow-hidden">
|
| 196 |
+
<div
|
| 197 |
+
className="h-full bg-[#b392ac] transition-all"
|
| 198 |
+
style={{ width: `${Math.round(score * 100)}%` }}
|
| 199 |
+
/>
|
| 200 |
+
</div>
|
| 201 |
+
<span className="text-[8px] text-gray-400 w-10 text-right">{Math.round(score * 100)}%</span>
|
| 202 |
+
</div>
|
| 203 |
+
))}
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
## Future Improvements
|
| 209 |
+
|
| 210 |
+
- **Incremental updates**: Score only new books instead of full dataset
|
| 211 |
+
- **Smaller model**: Try lightweight emotion classifiers (faster inference)
|
| 212 |
+
- **Multi-label tags**: Use text classification for predefined categories
|
| 213 |
+
- **Tag filtering**: Allow users to filter by specific tags in search
|
| 214 |
+
- **Emotion-based sorting**: Sort results by dominant emotion match
|
| 215 |
+
- **Caching**: Cache emotion inference results in Redis for API speedup
|
| 216 |
+
|
| 217 |
+
## Dependencies
|
| 218 |
+
|
| 219 |
+
```
|
| 220 |
+
scikit-learn # TF-IDF vectorization
|
| 221 |
+
transformers # Emotion classification
|
| 222 |
+
torch # Model inference
|
| 223 |
+
tqdm # Progress bars
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
## Notes
|
| 227 |
+
|
| 228 |
+
- Tags and emotions are **one-time computed** and stored in CSV
|
| 229 |
+
- No re-computation on API requests (instant serving)
|
| 230 |
+
- CSV file (242MB) is in `.gitignore` (too large for GitHub)
|
| 231 |
+
- To regenerate on a new machine, run both scripts sequentially:
|
| 232 |
+
1. `generate_tags.py` (~5 minutes)
|
| 233 |
+
2. `generate_emotions.py` (~7 hours on MPS for full dataset)
|
data/user_profiles.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"local": {
|
| 3 |
+
"favorites": [
|
| 4 |
+
"0006551688"
|
| 5 |
+
]
|
| 6 |
+
}
|
| 7 |
+
}
|
docker-compose.yml
CHANGED
|
@@ -23,20 +23,6 @@ services:
|
|
| 23 |
- redis_data:/data
|
| 24 |
restart: unless-stopped
|
| 25 |
|
| 26 |
-
ui:
|
| 27 |
-
build: .
|
| 28 |
-
command: python app.py
|
| 29 |
-
ports:
|
| 30 |
-
- "7860:7860"
|
| 31 |
-
volumes:
|
| 32 |
-
- ./data:/app/data
|
| 33 |
-
environment:
|
| 34 |
-
- GRADIO_SERVER_NAME=0.0.0.0
|
| 35 |
-
- API_URL=http://api:8000
|
| 36 |
-
depends_on:
|
| 37 |
-
- api
|
| 38 |
-
restart: unless-stopped
|
| 39 |
-
|
| 40 |
volumes:
|
| 41 |
chroma_data:
|
| 42 |
redis_data:
|
|
|
|
| 23 |
- redis_data:/data
|
| 24 |
restart: unless-stopped
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
volumes:
|
| 27 |
chroma_data:
|
| 28 |
redis_data:
|
interview_prep.md
CHANGED
|
@@ -64,7 +64,7 @@ Technically, the system is built as a containerized microservice using **FastAPI
|
|
| 64 |
|
| 65 |
### Similarities
|
| 66 |
* **Vector Database**: Usage of specialized vector stores (ChromaDB) and HNSW indexing.
|
| 67 |
-
* **Microservices**: Separation of concerns between UI (
|
| 68 |
* **Containerization**: Use of Docker for consistent deployment environments.
|
| 69 |
|
| 70 |
### Differences and Scalability Planning
|
|
@@ -107,7 +107,7 @@ Technically, the system is built as a containerized microservice using **FastAPI
|
|
| 107 |
| **Vector DB** | **ChromaDB** | Simplifies the stack by running in-process; tailored for LLM workloads. |
|
| 108 |
| **Cache** | **Redis** | Industry standard for key-value caching; low latency; persistence options. |
|
| 109 |
| **Container** | **Docker** | Ensures the complex dependency tree (PyTorch, Transformers, Redis client) works consistently across environments. |
|
| 110 |
-
| **Frontend** | **
|
| 111 |
|
| 112 |
---
|
| 113 |
|
|
|
|
| 64 |
|
| 65 |
### Similarities
|
| 66 |
* **Vector Database**: Usage of specialized vector stores (ChromaDB) and HNSW indexing.
|
| 67 |
+
* **Microservices**: Separation of concerns between UI (React), API (FastAPI), and Persistence (DB).
|
| 68 |
* **Containerization**: Use of Docker for consistent deployment environments.
|
| 69 |
|
| 70 |
### Differences and Scalability Planning
|
|
|
|
| 107 |
| **Vector DB** | **ChromaDB** | Simplifies the stack by running in-process; tailored for LLM workloads. |
|
| 108 |
| **Cache** | **Redis** | Industry standard for key-value caching; low latency; persistence options. |
|
| 109 |
| **Container** | **Docker** | Ensures the complex dependency tree (PyTorch, Transformers, Redis client) works consistently across environments. |
|
| 110 |
+
| **Frontend** | **React + Vite** | Modern component-based UI with Tailwind CSS; production-grade UX with fast development cycles. |
|
| 111 |
|
| 112 |
---
|
| 113 |
|
scripts/extract_review_sentences.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Extract representative review sentences from book descriptions using semantic similarity clustering.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python scripts/extract_review_sentences.py \
|
| 6 |
+
--input data/books_processed.csv \
|
| 7 |
+
--output data/books_processed.csv \
|
| 8 |
+
--top-n 5 \
|
| 9 |
+
--similarity-threshold 0.8
|
| 10 |
+
|
| 11 |
+
Notes:
|
| 12 |
+
- Splits descriptions into sentences
|
| 13 |
+
- Uses all-MiniLM-L6-v2 to vectorize sentences
|
| 14 |
+
- Clusters similar sentences (cosine similarity > threshold)
|
| 15 |
+
- Extracts representative sentences per book
|
| 16 |
+
- Stores as semicolon-separated review_highlights column
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import logging
|
| 23 |
+
import re
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import List
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
import pandas as pd
|
| 29 |
+
import torch
|
| 30 |
+
from transformers import AutoTokenizer, AutoModel
|
| 31 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 32 |
+
from tqdm import tqdm
|
| 33 |
+
|
| 34 |
+
logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
|
| 35 |
+
logger = logging.getLogger("extract_review_sentences")
|
| 36 |
+
|
| 37 |
+
MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def split_sentences(text: str) -> List[str]:
|
| 41 |
+
"""Split text into sentences using simple regex."""
|
| 42 |
+
if not text or pd.isna(text):
|
| 43 |
+
return []
|
| 44 |
+
|
| 45 |
+
text = str(text).strip()
|
| 46 |
+
# Split on sentence boundaries (., !, ?)
|
| 47 |
+
sentences = re.split(r'(?<=[.!?])\s+', text)
|
| 48 |
+
# Filter out very short sentences and clean
|
| 49 |
+
sentences = [
|
| 50 |
+
s.strip()
|
| 51 |
+
for s in sentences
|
| 52 |
+
if s.strip() and len(s.strip()) > 10
|
| 53 |
+
]
|
| 54 |
+
return sentences
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def cluster_sentences(sentences: List[str], embeddings: np.ndarray, threshold: float = 0.8) -> List[int]:
|
| 58 |
+
"""
|
| 59 |
+
Cluster sentences by cosine similarity.
|
| 60 |
+
Returns cluster ID for each sentence.
|
| 61 |
+
"""
|
| 62 |
+
if len(sentences) == 0:
|
| 63 |
+
return []
|
| 64 |
+
if len(sentences) == 1:
|
| 65 |
+
return [0]
|
| 66 |
+
|
| 67 |
+
# Compute pairwise similarity
|
| 68 |
+
similarity_matrix = cosine_similarity(embeddings)
|
| 69 |
+
|
| 70 |
+
# Simple clustering: assign each sentence to first similar cluster
|
| 71 |
+
clusters = [-1] * len(sentences)
|
| 72 |
+
current_cluster = 0
|
| 73 |
+
|
| 74 |
+
for i in range(len(sentences)):
|
| 75 |
+
if clusters[i] == -1:
|
| 76 |
+
clusters[i] = current_cluster
|
| 77 |
+
# Find all similar sentences
|
| 78 |
+
for j in range(i + 1, len(sentences)):
|
| 79 |
+
if clusters[j] == -1 and similarity_matrix[i, j] > threshold:
|
| 80 |
+
clusters[j] = current_cluster
|
| 81 |
+
current_cluster += 1
|
| 82 |
+
|
| 83 |
+
return clusters
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def extract_representative_sentences(
|
| 87 |
+
sentences: List[str],
|
| 88 |
+
embeddings: np.ndarray,
|
| 89 |
+
clusters: List[int],
|
| 90 |
+
top_n: int = 5
|
| 91 |
+
) -> List[str]:
|
| 92 |
+
"""
|
| 93 |
+
Extract one representative sentence from each cluster,
|
| 94 |
+
prioritizing longer/more informative sentences.
|
| 95 |
+
"""
|
| 96 |
+
if not sentences:
|
| 97 |
+
return []
|
| 98 |
+
|
| 99 |
+
unique_clusters = set(clusters)
|
| 100 |
+
representatives = []
|
| 101 |
+
|
| 102 |
+
for cluster_id in sorted(unique_clusters):
|
| 103 |
+
cluster_indices = [i for i, c in enumerate(clusters) if c == cluster_id]
|
| 104 |
+
if not cluster_indices:
|
| 105 |
+
continue
|
| 106 |
+
|
| 107 |
+
# Pick longest sentence in cluster as representative
|
| 108 |
+
best_idx = max(cluster_indices, key=lambda i: len(sentences[i]))
|
| 109 |
+
representatives.append((best_idx, sentences[best_idx]))
|
| 110 |
+
|
| 111 |
+
# Sort by original position and take top-n
|
| 112 |
+
representatives.sort(key=lambda x: x[0])
|
| 113 |
+
return [sent for _, sent in representatives[:top_n]]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def load_model(device: str | int | None):
|
| 117 |
+
"""Load sentence transformer model via transformers."""
|
| 118 |
+
logger.info("Loading model: %s", MODEL_NAME)
|
| 119 |
+
|
| 120 |
+
# Determine device
|
| 121 |
+
if isinstance(device, str) and device.lower() == "mps":
|
| 122 |
+
device_obj = torch.device("mps")
|
| 123 |
+
logger.info("Using MPS (Apple GPU)")
|
| 124 |
+
elif isinstance(device, int) and device >= 0:
|
| 125 |
+
device_obj = torch.device(f"cuda:{device}")
|
| 126 |
+
logger.info(f"Using CUDA device {device}")
|
| 127 |
+
else:
|
| 128 |
+
device_obj = torch.device("cpu")
|
| 129 |
+
logger.info("Using CPU")
|
| 130 |
+
|
| 131 |
+
# Load tokenizer and model
|
| 132 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
|
| 133 |
+
model = AutoModel.from_pretrained(MODEL_NAME).to(device_obj)
|
| 134 |
+
model.eval()
|
| 135 |
+
|
| 136 |
+
return tokenizer, model, device_obj
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def encode_sentences(sentences: List[str], tokenizer, model, device_obj, batch_size: int = 32) -> np.ndarray:
|
| 140 |
+
"""Encode sentences using the model (mean pooling)."""
|
| 141 |
+
embeddings = []
|
| 142 |
+
|
| 143 |
+
for i in range(0, len(sentences), batch_size):
|
| 144 |
+
batch = sentences[i:i+batch_size]
|
| 145 |
+
|
| 146 |
+
# Tokenize
|
| 147 |
+
encoded = tokenizer(
|
| 148 |
+
batch,
|
| 149 |
+
padding=True,
|
| 150 |
+
truncation=True,
|
| 151 |
+
max_length=512,
|
| 152 |
+
return_tensors="pt"
|
| 153 |
+
).to(device_obj)
|
| 154 |
+
|
| 155 |
+
# Get embeddings
|
| 156 |
+
with torch.no_grad():
|
| 157 |
+
model_output = model(**encoded)
|
| 158 |
+
# Mean pooling of last hidden state
|
| 159 |
+
attention_mask = encoded['attention_mask']
|
| 160 |
+
last_hidden = model_output.last_hidden_state
|
| 161 |
+
mask_expanded = attention_mask.unsqueeze(-1).expand(last_hidden.size()).float()
|
| 162 |
+
sum_hidden = (last_hidden * mask_expanded).sum(1)
|
| 163 |
+
sum_mask = mask_expanded.sum(1)
|
| 164 |
+
mean_embeddings = sum_hidden / sum_mask.clamp(min=1e-9)
|
| 165 |
+
|
| 166 |
+
embeddings.append(mean_embeddings.cpu().numpy())
|
| 167 |
+
|
| 168 |
+
return np.vstack(embeddings) if embeddings else np.array([])
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def main():
|
| 172 |
+
parser = argparse.ArgumentParser(description="Extract representative review sentences")
|
| 173 |
+
parser.add_argument("--input", type=Path, default=Path("data/books_processed.csv"))
|
| 174 |
+
parser.add_argument("--output", type=Path, default=Path("data/books_processed.csv"))
|
| 175 |
+
parser.add_argument("--top-n", type=int, default=5, help="Top N sentences to extract per book")
|
| 176 |
+
parser.add_argument("--similarity-threshold", type=float, default=0.8, help="Cosine similarity threshold for clustering")
|
| 177 |
+
parser.add_argument("--device", type=str, default="0", help="Device: 0 (CUDA), -1 (CPU), or mps (Apple)")
|
| 178 |
+
parser.add_argument("--batch-size", type=int, default=32, help="Batch size for embedding")
|
| 179 |
+
parser.add_argument("--max-rows", type=int, default=None, help="Limit to N rows (for testing)")
|
| 180 |
+
|
| 181 |
+
args = parser.parse_args()
|
| 182 |
+
|
| 183 |
+
if not args.input.exists():
|
| 184 |
+
raise FileNotFoundError(f"Input file not found: {args.input}")
|
| 185 |
+
|
| 186 |
+
logger.info("Loading data from %s", args.input)
|
| 187 |
+
df = pd.read_csv(args.input)
|
| 188 |
+
|
| 189 |
+
if args.max_rows:
|
| 190 |
+
df = df.head(args.max_rows)
|
| 191 |
+
logger.info(f"Limited to {args.max_rows} rows for testing")
|
| 192 |
+
|
| 193 |
+
if "description" not in df.columns:
|
| 194 |
+
raise ValueError("Input CSV must have a 'description' column")
|
| 195 |
+
|
| 196 |
+
# Load model
|
| 197 |
+
device = int(args.device) if args.device.lstrip('-').isdigit() else args.device
|
| 198 |
+
tokenizer, model, device_obj = load_model(device)
|
| 199 |
+
|
| 200 |
+
# Process each book
|
| 201 |
+
review_highlights = []
|
| 202 |
+
|
| 203 |
+
logger.info(f"Processing {len(df)} books to extract review sentences...")
|
| 204 |
+
|
| 205 |
+
for idx, row in tqdm(df.iterrows(), total=len(df)):
|
| 206 |
+
description = row["description"]
|
| 207 |
+
|
| 208 |
+
# Split into sentences
|
| 209 |
+
sentences = split_sentences(description)
|
| 210 |
+
|
| 211 |
+
if not sentences:
|
| 212 |
+
review_highlights.append("")
|
| 213 |
+
continue
|
| 214 |
+
|
| 215 |
+
# Embed sentences
|
| 216 |
+
embeddings = encode_sentences(sentences, tokenizer, model, device_obj, batch_size=args.batch_size)
|
| 217 |
+
|
| 218 |
+
# Cluster similar sentences
|
| 219 |
+
clusters = cluster_sentences(sentences, embeddings, threshold=args.similarity_threshold)
|
| 220 |
+
|
| 221 |
+
# Extract representatives
|
| 222 |
+
representatives = extract_representative_sentences(
|
| 223 |
+
sentences,
|
| 224 |
+
embeddings,
|
| 225 |
+
clusters,
|
| 226 |
+
top_n=args.top_n
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
# Store as semicolon-separated string
|
| 230 |
+
highlights_str = ";".join(representatives)
|
| 231 |
+
review_highlights.append(highlights_str)
|
| 232 |
+
|
| 233 |
+
# Add column to dataframe
|
| 234 |
+
df["review_highlights"] = review_highlights
|
| 235 |
+
|
| 236 |
+
logger.info("Writing output to %s", args.output)
|
| 237 |
+
df.to_csv(args.output, index=False)
|
| 238 |
+
|
| 239 |
+
# Print sample
|
| 240 |
+
logger.info("Sample review highlights:")
|
| 241 |
+
for i in range(min(3, len(df))):
|
| 242 |
+
highlights = review_highlights[i]
|
| 243 |
+
if highlights:
|
| 244 |
+
print(f"\nBook {i+1}: {df.iloc[i]['title']}")
|
| 245 |
+
for sent in highlights.split(";")[:2]:
|
| 246 |
+
print(f" • {sent[:80]}...")
|
| 247 |
+
|
| 248 |
+
logger.info("Done. Added review_highlights column with %d entries", len(review_highlights))
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
if __name__ == "__main__":
|
| 252 |
+
main()
|
scripts/generate_emotions.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Populate emotion scores (joy, sadness, fear, anger, surprise) from book descriptions.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python scripts/generate_emotions.py \
|
| 6 |
+
--input data/books_processed.csv \
|
| 7 |
+
--output data/books_processed.csv \
|
| 8 |
+
--batch-size 16
|
| 9 |
+
|
| 10 |
+
Notes:
|
| 11 |
+
- Uses a lightweight transformer classifier (j-hartmann/emotion-english-distilroberta-base).
|
| 12 |
+
- Runs on CPU by default; set CUDA via env if available.
|
| 13 |
+
- Processes in batches to avoid memory spikes.
|
| 14 |
+
- Adds/overwrites columns: joy, sadness, fear, anger, surprise.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import logging
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Dict, List
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
import pandas as pd
|
| 26 |
+
import torch
|
| 27 |
+
from transformers import pipeline
|
| 28 |
+
from tqdm import tqdm
|
| 29 |
+
|
| 30 |
+
logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
|
| 31 |
+
logger = logging.getLogger("generate_emotions")
|
| 32 |
+
|
| 33 |
+
TARGET_LABELS = ["joy", "sadness", "fear", "anger", "surprise"]
|
| 34 |
+
MODEL_NAME = "j-hartmann/emotion-english-distilroberta-base"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def load_model(device: str | int | None):
|
| 38 |
+
logger.info("Loading model: %s", MODEL_NAME)
|
| 39 |
+
|
| 40 |
+
if isinstance(device, str) and device.lower() == "mps":
|
| 41 |
+
if not torch.backends.mps.is_available():
|
| 42 |
+
raise RuntimeError("MPS requested but not available. Check PyTorch MPS build.")
|
| 43 |
+
device_map = {"": "mps"}
|
| 44 |
+
logger.info("Using MPS (Apple GPU)")
|
| 45 |
+
return pipeline(
|
| 46 |
+
"text-classification",
|
| 47 |
+
model=MODEL_NAME,
|
| 48 |
+
tokenizer=MODEL_NAME,
|
| 49 |
+
return_all_scores=True,
|
| 50 |
+
device_map=device_map,
|
| 51 |
+
torch_dtype=torch.float16,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# CUDA or CPU path (device as int or None)
|
| 55 |
+
device_id = device if isinstance(device, int) else -1
|
| 56 |
+
if device_id >= 0:
|
| 57 |
+
logger.info("Using CUDA device %s", device_id)
|
| 58 |
+
else:
|
| 59 |
+
logger.info("Using CPU")
|
| 60 |
+
return pipeline(
|
| 61 |
+
"text-classification",
|
| 62 |
+
model=MODEL_NAME,
|
| 63 |
+
tokenizer=MODEL_NAME,
|
| 64 |
+
return_all_scores=True,
|
| 65 |
+
device=device_id,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def scores_to_vector(scores: List[Dict[str, float]]) -> Dict[str, float]:
|
| 70 |
+
# scores: list of dicts with keys label/score
|
| 71 |
+
mapped = {k: 0.0 for k in TARGET_LABELS}
|
| 72 |
+
for item in scores:
|
| 73 |
+
label = item.get("label", "").lower()
|
| 74 |
+
if label in mapped:
|
| 75 |
+
mapped[label] = float(item.get("score", 0.0))
|
| 76 |
+
return mapped
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def main():
|
| 80 |
+
ap = argparse.ArgumentParser(description="Generate emotion scores from descriptions")
|
| 81 |
+
ap.add_argument("--input", type=Path, default=Path("data/books_processed.csv"))
|
| 82 |
+
ap.add_argument("--output", type=Path, default=Path("data/books_processed.csv"))
|
| 83 |
+
ap.add_argument("--batch-size", type=int, default=16)
|
| 84 |
+
ap.add_argument("--max-rows", type=int, default=None, help="Optional cap for debugging")
|
| 85 |
+
ap.add_argument("--device", default=None, help="'mps' for Apple GPU, CUDA device id, or omit for CPU")
|
| 86 |
+
ap.add_argument("--checkpoint", type=int, default=5000, help="Rows between checkpoint writes")
|
| 87 |
+
ap.add_argument("--resume", action="store_true", help="Resume if output exists (skip rows with scores)")
|
| 88 |
+
args = ap.parse_args()
|
| 89 |
+
|
| 90 |
+
if not args.input.exists():
|
| 91 |
+
raise FileNotFoundError(f"Input file not found: {args.input}")
|
| 92 |
+
|
| 93 |
+
logger.info("Loading data from %s", args.input)
|
| 94 |
+
df = pd.read_csv(args.input)
|
| 95 |
+
if "description" not in df.columns:
|
| 96 |
+
raise ValueError("Input CSV must have a 'description' column")
|
| 97 |
+
|
| 98 |
+
if args.max_rows:
|
| 99 |
+
df = df.head(args.max_rows)
|
| 100 |
+
logger.info("Truncated to %d rows for max_rows", len(df))
|
| 101 |
+
|
| 102 |
+
n = len(df)
|
| 103 |
+
# Normalize device arg
|
| 104 |
+
dev: str | int | None
|
| 105 |
+
if args.device is None:
|
| 106 |
+
dev = None
|
| 107 |
+
else:
|
| 108 |
+
if isinstance(args.device, str) and args.device.lower() == "mps":
|
| 109 |
+
dev = "mps"
|
| 110 |
+
else:
|
| 111 |
+
try:
|
| 112 |
+
dev = int(args.device)
|
| 113 |
+
except ValueError:
|
| 114 |
+
dev = None
|
| 115 |
+
model = load_model(dev)
|
| 116 |
+
|
| 117 |
+
# Prepare containers
|
| 118 |
+
for col in TARGET_LABELS:
|
| 119 |
+
if col not in df.columns:
|
| 120 |
+
df[col] = 0.0
|
| 121 |
+
|
| 122 |
+
# Resume support: if output exists, and resume flag set, load scores
|
| 123 |
+
if args.resume and args.output.exists():
|
| 124 |
+
logger.info("Resume enabled: loading existing output from %s", args.output)
|
| 125 |
+
df_prev = pd.read_csv(args.output)
|
| 126 |
+
for col in TARGET_LABELS:
|
| 127 |
+
if col in df_prev.columns:
|
| 128 |
+
df[col] = df_prev[col]
|
| 129 |
+
|
| 130 |
+
texts = df["description"].fillna("").astype(str).tolist()
|
| 131 |
+
batch = args.batch_size
|
| 132 |
+
checkpoint = max(1, args.checkpoint)
|
| 133 |
+
|
| 134 |
+
logger.info("Scoring %d descriptions (batch=%d, checkpoint=%d)...", n, batch, checkpoint)
|
| 135 |
+
total_batches = (n + batch - 1) // batch
|
| 136 |
+
for bidx, start in enumerate(tqdm(range(0, n, batch), total=total_batches)):
|
| 137 |
+
end = min(start + batch, n)
|
| 138 |
+
|
| 139 |
+
# Skip already-computed rows when resuming (all scores > 0)
|
| 140 |
+
if args.resume:
|
| 141 |
+
existing = df.loc[start:end-1, TARGET_LABELS].values
|
| 142 |
+
if np.all(existing > 0):
|
| 143 |
+
continue
|
| 144 |
+
|
| 145 |
+
chunk = texts[start:end]
|
| 146 |
+
outputs = model(chunk, truncation=True, max_length=512, top_k=None)
|
| 147 |
+
for i, out in enumerate(outputs):
|
| 148 |
+
vec = scores_to_vector(out)
|
| 149 |
+
idx = start + i
|
| 150 |
+
for col in TARGET_LABELS:
|
| 151 |
+
df.at[idx, col] = vec[col]
|
| 152 |
+
|
| 153 |
+
# periodic checkpoint write
|
| 154 |
+
if (start > 0) and ((start % checkpoint) == 0):
|
| 155 |
+
df.to_csv(args.output, index=False)
|
| 156 |
+
|
| 157 |
+
logger.info("Writing to %s", args.output)
|
| 158 |
+
df.to_csv(args.output, index=False)
|
| 159 |
+
logger.info("Done. Example row: %s", df.head(1)[TARGET_LABELS].to_dict(orient="records"))
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
if __name__ == "__main__":
|
| 163 |
+
main()
|
scripts/generate_tags.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Generate per-book tags from aggregated review text (description field) using TF-IDF.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python scripts/generate_tags.py \
|
| 6 |
+
--input data/books_processed.csv \
|
| 7 |
+
--output data/books_processed.csv \
|
| 8 |
+
--top-n 8
|
| 9 |
+
|
| 10 |
+
Notes:
|
| 11 |
+
- Uses unigrams + bigrams with English stopwords and a small domain stoplist.
|
| 12 |
+
- Filters out very short tokens and common boilerplate words.
|
| 13 |
+
- Writes a semicolon-joined `tags` column back to the CSV.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import html
|
| 20 |
+
import logging
|
| 21 |
+
import re
|
| 22 |
+
import unicodedata
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Iterable, List
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
import pandas as pd
|
| 28 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 29 |
+
|
| 30 |
+
logging.basicConfig(
|
| 31 |
+
level=logging.INFO,
|
| 32 |
+
format="[%(levelname)s] %(message)s",
|
| 33 |
+
)
|
| 34 |
+
logger = logging.getLogger("generate_tags")
|
| 35 |
+
|
| 36 |
+
DOMAIN_STOPWORDS = {
|
| 37 |
+
"book", "books", "story", "stories", "author", "authors", "novel", "fiction",
|
| 38 |
+
"reader", "readers", "reading", "write", "writes", "writing", "written",
|
| 39 |
+
"character", "characters", "plot", "series", "chapter", "chapters", "pages",
|
| 40 |
+
"edition", "copy", "copies", "hardcover", "paperback", "kindle",
|
| 41 |
+
# HTML / noise
|
| 42 |
+
"amp", "nbsp", "lt", "gt",
|
| 43 |
+
# Very common filler
|
| 44 |
+
"com", "http", "https", "www",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
TOKEN_RE = re.compile(r"^[a-zA-Z][a-zA-Z\-']{2,}$")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def normalize_text(text: str) -> str:
|
| 51 |
+
"""Clean text: HTML decode, strip control chars, collapse spaces."""
|
| 52 |
+
t = html.unescape(str(text))
|
| 53 |
+
t = unicodedata.normalize("NFKC", t)
|
| 54 |
+
# Remove stray HTML entities and URLs
|
| 55 |
+
t = re.sub(r"&[a-zA-Z]+;", " ", t)
|
| 56 |
+
t = re.sub(r"https?://\S+", " ", t)
|
| 57 |
+
# Collapse whitespace
|
| 58 |
+
t = re.sub(r"\s+", " ", t)
|
| 59 |
+
return t.strip()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def filter_tokens(tokens: Iterable[str], max_tokens: int) -> List[str]:
|
| 63 |
+
"""Filter and deduplicate tokens, preserving order until max_tokens reached."""
|
| 64 |
+
seen = set()
|
| 65 |
+
result: List[str] = []
|
| 66 |
+
for tok in tokens:
|
| 67 |
+
t = tok.strip().lower()
|
| 68 |
+
if not t:
|
| 69 |
+
continue
|
| 70 |
+
if t in seen:
|
| 71 |
+
continue
|
| 72 |
+
if t in DOMAIN_STOPWORDS:
|
| 73 |
+
continue
|
| 74 |
+
if len(t) < 3:
|
| 75 |
+
continue
|
| 76 |
+
if not TOKEN_RE.match(t):
|
| 77 |
+
continue
|
| 78 |
+
seen.add(t)
|
| 79 |
+
result.append(t)
|
| 80 |
+
if len(result) >= max_tokens:
|
| 81 |
+
break
|
| 82 |
+
return result
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def compute_tags(corpus: List[str], top_n: int, max_features: int, min_df: int, max_df: float) -> List[str]:
|
| 86 |
+
vectorizer = TfidfVectorizer(
|
| 87 |
+
stop_words="english",
|
| 88 |
+
ngram_range=(1, 2),
|
| 89 |
+
max_features=max_features,
|
| 90 |
+
min_df=min_df,
|
| 91 |
+
max_df=max_df,
|
| 92 |
+
dtype=np.float32,
|
| 93 |
+
lowercase=True,
|
| 94 |
+
)
|
| 95 |
+
logger.info("Fitting TF-IDF on %d documents...", len(corpus))
|
| 96 |
+
tfidf = vectorizer.fit_transform(corpus)
|
| 97 |
+
terms = vectorizer.get_feature_names_out()
|
| 98 |
+
|
| 99 |
+
tags: List[str] = []
|
| 100 |
+
for i in range(tfidf.shape[0]):
|
| 101 |
+
row = tfidf.getrow(i)
|
| 102 |
+
if row.nnz == 0:
|
| 103 |
+
tags.append("")
|
| 104 |
+
continue
|
| 105 |
+
data = row.data
|
| 106 |
+
indices = row.indices
|
| 107 |
+
# Pick top_n by weight
|
| 108 |
+
if data.shape[0] <= top_n:
|
| 109 |
+
top_local = np.argsort(data)[::-1]
|
| 110 |
+
else:
|
| 111 |
+
part = np.argpartition(data, -top_n)[-top_n:]
|
| 112 |
+
top_local = part[np.argsort(data[part])[::-1]]
|
| 113 |
+
ordered_tokens = [terms[indices[j]] for j in top_local]
|
| 114 |
+
cleaned = filter_tokens(ordered_tokens, max_tokens=top_n)
|
| 115 |
+
tags.append(";".join(cleaned))
|
| 116 |
+
return tags
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def main():
|
| 120 |
+
parser = argparse.ArgumentParser(description="Generate per-book tags from descriptions")
|
| 121 |
+
parser.add_argument("--input", type=Path, default=Path("data/books_processed.csv"))
|
| 122 |
+
parser.add_argument("--output", type=Path, default=Path("data/books_processed.csv"))
|
| 123 |
+
parser.add_argument("--top-n", type=int, default=8)
|
| 124 |
+
parser.add_argument("--max-features", type=int, default=60000)
|
| 125 |
+
parser.add_argument("--min-df", type=int, default=5)
|
| 126 |
+
parser.add_argument("--max-df", type=float, default=0.5)
|
| 127 |
+
args = parser.parse_args()
|
| 128 |
+
|
| 129 |
+
if not args.input.exists():
|
| 130 |
+
raise FileNotFoundError(f"Input file not found: {args.input}")
|
| 131 |
+
|
| 132 |
+
logger.info("Loading data from %s", args.input)
|
| 133 |
+
df = pd.read_csv(args.input)
|
| 134 |
+
if "description" not in df.columns:
|
| 135 |
+
raise ValueError("Input CSV must have a 'description' column")
|
| 136 |
+
|
| 137 |
+
corpus = [normalize_text(x) for x in df["description"].fillna("").astype(str).tolist()]
|
| 138 |
+
tags = compute_tags(
|
| 139 |
+
corpus,
|
| 140 |
+
top_n=args.top_n,
|
| 141 |
+
max_features=args.max_features,
|
| 142 |
+
min_df=args.min_df,
|
| 143 |
+
max_df=args.max_df,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
df["tags"] = tags
|
| 147 |
+
logger.info("Writing tagged data to %s", args.output)
|
| 148 |
+
df.to_csv(args.output, index=False)
|
| 149 |
+
logger.info("Done. Sample tags: %s", tags[0:3])
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
if __name__ == "__main__":
|
| 153 |
+
main()
|
src/cover_fetcher.py
CHANGED
|
@@ -21,63 +21,64 @@ API Rate Limits:
|
|
| 21 |
Author: Modified 2026-01-06
|
| 22 |
"""
|
| 23 |
import requests
|
| 24 |
-
from
|
| 25 |
import time
|
| 26 |
from functools import lru_cache
|
| 27 |
|
| 28 |
-
# Placeholder image for books without covers
|
| 29 |
-
|
|
|
|
| 30 |
|
| 31 |
@lru_cache(maxsize=1000)
|
| 32 |
-
def fetch_book_cover(isbn: str, title: str = "") -> str:
|
| 33 |
"""
|
| 34 |
-
Fetch book cover URL
|
| 35 |
-
|
| 36 |
-
Args:
|
| 37 |
-
isbn: ISBN-13 of the book
|
| 38 |
-
title: Book title (used for placeholder text)
|
| 39 |
-
|
| 40 |
Returns:
|
| 41 |
-
|
| 42 |
"""
|
|
|
|
|
|
|
|
|
|
| 43 |
# Try Google Books API first
|
| 44 |
try:
|
| 45 |
url = f"https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}"
|
| 46 |
response = requests.get(url, timeout=2)
|
| 47 |
-
|
| 48 |
if response.status_code == 200:
|
| 49 |
data = response.json()
|
| 50 |
if data.get("totalItems", 0) > 0:
|
| 51 |
items = data.get("items", [])
|
| 52 |
if items:
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
image_links.get("extraLarge") or
|
| 57 |
image_links.get("large") or
|
| 58 |
image_links.get("medium") or
|
| 59 |
image_links.get("small") or
|
| 60 |
image_links.get("thumbnail")
|
| 61 |
)
|
| 62 |
-
if
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
# Try Open Library as fallback
|
| 69 |
-
try:
|
| 70 |
-
# Open Library cover API
|
| 71 |
-
url = f"https://covers.openlibrary.org/b/isbn/{isbn}-M.jpg"
|
| 72 |
-
# Quick HEAD request to check if cover exists
|
| 73 |
-
response = requests.head(url, timeout=1)
|
| 74 |
-
if response.status_code == 200:
|
| 75 |
-
return url
|
| 76 |
except Exception:
|
| 77 |
-
pass
|
| 78 |
-
|
| 79 |
-
#
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
|
| 83 |
def fetch_covers_batch(books_data: list) -> list:
|
|
@@ -93,7 +94,10 @@ def fetch_covers_batch(books_data: list) -> list:
|
|
| 93 |
for book in books_data:
|
| 94 |
isbn = book.get("isbn", "")
|
| 95 |
title = book.get("title", "")
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
| 97 |
# Small delay to avoid rate limiting
|
| 98 |
time.sleep(0.05)
|
| 99 |
|
|
|
|
| 21 |
Author: Modified 2026-01-06
|
| 22 |
"""
|
| 23 |
import requests
|
| 24 |
+
from pathlib import Path
|
| 25 |
import time
|
| 26 |
from functools import lru_cache
|
| 27 |
|
| 28 |
+
# Placeholder image for books without covers (local asset)
|
| 29 |
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 30 |
+
PLACEHOLDER_COVER = str(PROJECT_ROOT / "assets" / "cover-not-found.jpg")
|
| 31 |
|
| 32 |
@lru_cache(maxsize=1000)
|
| 33 |
+
def fetch_book_cover(isbn: str, title: str = "") -> tuple[str, str]:
|
| 34 |
"""
|
| 35 |
+
Fetch book cover URL (Google Books -> Open Library) and best-effort authors.
|
| 36 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
Returns:
|
| 38 |
+
(cover_url, authors_str)
|
| 39 |
"""
|
| 40 |
+
cover = PLACEHOLDER_COVER
|
| 41 |
+
authors_str = "Unknown"
|
| 42 |
+
|
| 43 |
# Try Google Books API first
|
| 44 |
try:
|
| 45 |
url = f"https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}"
|
| 46 |
response = requests.get(url, timeout=2)
|
| 47 |
+
|
| 48 |
if response.status_code == 200:
|
| 49 |
data = response.json()
|
| 50 |
if data.get("totalItems", 0) > 0:
|
| 51 |
items = data.get("items", [])
|
| 52 |
if items:
|
| 53 |
+
volume = items[0].get("volumeInfo", {})
|
| 54 |
+
image_links = volume.get("imageLinks", {})
|
| 55 |
+
cover_candidate = (
|
| 56 |
image_links.get("extraLarge") or
|
| 57 |
image_links.get("large") or
|
| 58 |
image_links.get("medium") or
|
| 59 |
image_links.get("small") or
|
| 60 |
image_links.get("thumbnail")
|
| 61 |
)
|
| 62 |
+
if cover_candidate:
|
| 63 |
+
cover = cover_candidate.replace("http://", "https://")
|
| 64 |
+
|
| 65 |
+
authors = volume.get("authors") or []
|
| 66 |
+
if authors:
|
| 67 |
+
authors_str = ", ".join(authors)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
except Exception:
|
| 69 |
+
pass # Fall through to Open Library
|
| 70 |
+
|
| 71 |
+
# Try Open Library as fallback for cover (author data not available there)
|
| 72 |
+
if cover == PLACEHOLDER_COVER:
|
| 73 |
+
try:
|
| 74 |
+
url = f"https://covers.openlibrary.org/b/isbn/{isbn}-M.jpg"
|
| 75 |
+
response = requests.head(url, timeout=1)
|
| 76 |
+
if response.status_code == 200:
|
| 77 |
+
cover = url
|
| 78 |
+
except Exception:
|
| 79 |
+
pass
|
| 80 |
+
|
| 81 |
+
return cover, authors_str
|
| 82 |
|
| 83 |
|
| 84 |
def fetch_covers_batch(books_data: list) -> list:
|
|
|
|
| 94 |
for book in books_data:
|
| 95 |
isbn = book.get("isbn", "")
|
| 96 |
title = book.get("title", "")
|
| 97 |
+
cover, authors = fetch_book_cover(isbn, title)
|
| 98 |
+
book["thumbnail"] = cover
|
| 99 |
+
if authors != "Unknown":
|
| 100 |
+
book["authors"] = authors
|
| 101 |
# Small delay to avoid rate limiting
|
| 102 |
time.sleep(0.05)
|
| 103 |
|
src/main.py
CHANGED
|
@@ -1,13 +1,17 @@
|
|
| 1 |
from fastapi import FastAPI, HTTPException, Request
|
|
|
|
| 2 |
from fastapi.responses import Response
|
| 3 |
from pydantic import BaseModel
|
| 4 |
-
from typing import List
|
| 5 |
import time
|
| 6 |
import prometheus_client
|
| 7 |
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
|
| 8 |
|
| 9 |
from src.recommender import BookRecommender
|
| 10 |
from src.utils import setup_logger
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
logger = setup_logger(__name__)
|
| 13 |
|
|
@@ -21,6 +25,20 @@ app = FastAPI(
|
|
| 21 |
version="1.0.0"
|
| 22 |
)
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
# --- Observability Middleware ---
|
| 25 |
@app.middleware("http")
|
| 26 |
async def prometheus_middleware(request: Request, call_next):
|
|
@@ -74,10 +92,24 @@ class BookResponse(BaseModel):
|
|
| 74 |
description: str
|
| 75 |
thumbnail: str
|
| 76 |
caption: str
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
class RecommendationResponse(BaseModel):
|
| 79 |
recommendations: List[BookResponse]
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
@app.get("/health")
|
| 82 |
async def health_check():
|
| 83 |
"""Health check endpoint to verify service status."""
|
|
@@ -115,6 +147,46 @@ async def get_tones():
|
|
| 115 |
return {"tones": recommender.get_tones()}
|
| 116 |
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
@app.get("/benchmark")
|
| 119 |
async def run_benchmark():
|
| 120 |
"""
|
|
|
|
| 1 |
from fastapi import FastAPI, HTTPException, Request
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
from fastapi.responses import Response
|
| 4 |
from pydantic import BaseModel
|
| 5 |
+
from typing import List, Optional, Dict, Any
|
| 6 |
import time
|
| 7 |
import prometheus_client
|
| 8 |
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
|
| 9 |
|
| 10 |
from src.recommender import BookRecommender
|
| 11 |
from src.utils import setup_logger
|
| 12 |
+
from src.user.profile_store import add_favorite, list_favorites
|
| 13 |
+
from src.marketing.persona import build_persona
|
| 14 |
+
from src.marketing.highlights import generate_highlights
|
| 15 |
|
| 16 |
logger = setup_logger(__name__)
|
| 17 |
|
|
|
|
| 25 |
version="1.0.0"
|
| 26 |
)
|
| 27 |
|
| 28 |
+
# Allow local frontend dev origins
|
| 29 |
+
app.add_middleware(
|
| 30 |
+
CORSMiddleware,
|
| 31 |
+
allow_origins=[
|
| 32 |
+
"http://localhost:5173",
|
| 33 |
+
"http://127.0.0.1:5173",
|
| 34 |
+
"http://localhost:3000",
|
| 35 |
+
"http://127.0.0.1:3000",
|
| 36 |
+
],
|
| 37 |
+
allow_credentials=True,
|
| 38 |
+
allow_methods=["*"],
|
| 39 |
+
allow_headers=["*"],
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
# --- Observability Middleware ---
|
| 43 |
@app.middleware("http")
|
| 44 |
async def prometheus_middleware(request: Request, call_next):
|
|
|
|
| 92 |
description: str
|
| 93 |
thumbnail: str
|
| 94 |
caption: str
|
| 95 |
+
tags: List[str] = []
|
| 96 |
+
emotions: Dict[str, float] = {}
|
| 97 |
+
review_highlights: List[str] = []
|
| 98 |
+
average_rating: float = 0.0
|
| 99 |
|
| 100 |
class RecommendationResponse(BaseModel):
|
| 101 |
recommendations: List[BookResponse]
|
| 102 |
|
| 103 |
+
|
| 104 |
+
class FavoriteRequest(BaseModel):
|
| 105 |
+
user_id: Optional[str] = "local"
|
| 106 |
+
isbn: str
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class HighlightsRequest(BaseModel):
|
| 110 |
+
isbn: str
|
| 111 |
+
user_id: Optional[str] = "local"
|
| 112 |
+
|
| 113 |
@app.get("/health")
|
| 114 |
async def health_check():
|
| 115 |
"""Health check endpoint to verify service status."""
|
|
|
|
| 147 |
return {"tones": recommender.get_tones()}
|
| 148 |
|
| 149 |
|
| 150 |
+
# --- Favorites & Persona & Highlights ---
|
| 151 |
+
@app.post("/favorites/add")
|
| 152 |
+
async def favorites_add(req: FavoriteRequest):
|
| 153 |
+
if not recommender:
|
| 154 |
+
raise HTTPException(status_code=503, detail="Service not ready")
|
| 155 |
+
try:
|
| 156 |
+
count = add_favorite(req.user_id or "local", req.isbn)
|
| 157 |
+
return {"status": "ok", "favorites_count": count}
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.error(f"favorites_add error: {e}")
|
| 160 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@app.get("/user/{user_id}/persona")
|
| 164 |
+
async def user_persona(user_id: str):
|
| 165 |
+
if not recommender:
|
| 166 |
+
raise HTTPException(status_code=503, detail="Service not ready")
|
| 167 |
+
try:
|
| 168 |
+
favs = list_favorites(user_id)
|
| 169 |
+
persona = build_persona(favs, recommender.books)
|
| 170 |
+
return {"user_id": user_id, "favorites": favs, "persona": persona}
|
| 171 |
+
except Exception as e:
|
| 172 |
+
logger.error(f"user_persona error: {e}")
|
| 173 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@app.post("/marketing/highlights")
|
| 177 |
+
async def marketing_highlights(req: HighlightsRequest):
|
| 178 |
+
if not recommender:
|
| 179 |
+
raise HTTPException(status_code=503, detail="Service not ready")
|
| 180 |
+
try:
|
| 181 |
+
favs = list_favorites(req.user_id or "local")
|
| 182 |
+
persona = build_persona(favs, recommender.books)
|
| 183 |
+
result = generate_highlights(req.isbn, persona, recommender.books)
|
| 184 |
+
return {"persona": persona, "highlights": result.get("highlights", []), "meta": result}
|
| 185 |
+
except Exception as e:
|
| 186 |
+
logger.error(f"marketing_highlights error: {e}")
|
| 187 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 188 |
+
|
| 189 |
+
|
| 190 |
@app.get("/benchmark")
|
| 191 |
async def run_benchmark():
|
| 192 |
"""
|
src/marketing/highlights.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, List, Any
|
| 2 |
+
import pandas as pd
|
| 3 |
+
|
| 4 |
+
from src.utils import setup_logger
|
| 5 |
+
|
| 6 |
+
logger = setup_logger(__name__)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _first_words(text: str, n: int = 30) -> str:
|
| 10 |
+
try:
|
| 11 |
+
words = str(text).split()
|
| 12 |
+
return (" ".join(words[:n]) + ("..." if len(words) > n else "")).strip()
|
| 13 |
+
except Exception:
|
| 14 |
+
return ""
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def generate_highlights(isbn: str, persona: Dict[str, Any], books: pd.DataFrame) -> Dict[str, Any]:
|
| 18 |
+
"""
|
| 19 |
+
Generate a natural, concise highlight about the book based on its attributes.
|
| 20 |
+
Returns { highlights: List[str], title: str, authors: str, category: str, description: str }
|
| 21 |
+
"""
|
| 22 |
+
book_row = books[books["isbn13"].astype(str) == str(isbn)]
|
| 23 |
+
if book_row.empty:
|
| 24 |
+
return {
|
| 25 |
+
"highlights": ["This book brings unique perspectives worth exploring."],
|
| 26 |
+
"persona_summary": persona.get("summary", ""),
|
| 27 |
+
"title": "",
|
| 28 |
+
"authors": "Unknown",
|
| 29 |
+
"category": "",
|
| 30 |
+
"description": ""
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
row = book_row.iloc[0]
|
| 34 |
+
title = str(row.get("title", ""))
|
| 35 |
+
authors_raw = str(row.get("authors", ""))
|
| 36 |
+
category = str(row.get("simple_categories", ""))
|
| 37 |
+
desc = str(row.get("description", ""))
|
| 38 |
+
|
| 39 |
+
# Extract tags and emotions
|
| 40 |
+
tags_raw = str(row.get("tags", ""))
|
| 41 |
+
tags = [t.strip() for t in tags_raw.split(";") if t.strip()][:3] # top 3 tags
|
| 42 |
+
|
| 43 |
+
emotions = {
|
| 44 |
+
"joy": float(row.get("joy", 0.0)),
|
| 45 |
+
"sadness": float(row.get("sadness", 0.0)),
|
| 46 |
+
"fear": float(row.get("fear", 0.0)),
|
| 47 |
+
"anger": float(row.get("anger", 0.0)),
|
| 48 |
+
"surprise": float(row.get("surprise", 0.0)),
|
| 49 |
+
}
|
| 50 |
+
dominant_emotion = max(emotions.items(), key=lambda x: x[1])[0] if emotions else None
|
| 51 |
+
|
| 52 |
+
# Build natural language highlight
|
| 53 |
+
parts = []
|
| 54 |
+
|
| 55 |
+
# Emotional tone
|
| 56 |
+
emotion_map = {
|
| 57 |
+
"joy": "uplifting and heartwarming",
|
| 58 |
+
"sadness": "deeply moving and contemplative",
|
| 59 |
+
"fear": "gripping and suspenseful",
|
| 60 |
+
"anger": "powerful and thought-provoking",
|
| 61 |
+
"surprise": "unexpected and engaging"
|
| 62 |
+
}
|
| 63 |
+
if dominant_emotion and emotions.get(dominant_emotion, 0) > 0.3:
|
| 64 |
+
parts.append(f"A {emotion_map.get(dominant_emotion, 'compelling')} {category.lower() if category else 'read'}")
|
| 65 |
+
elif category:
|
| 66 |
+
parts.append(f"An engaging {category.lower()} work")
|
| 67 |
+
else:
|
| 68 |
+
parts.append("A captivating read")
|
| 69 |
+
|
| 70 |
+
# Theme tags
|
| 71 |
+
if tags:
|
| 72 |
+
parts.append(f"exploring themes of {', '.join(tags)}")
|
| 73 |
+
|
| 74 |
+
# Author mention (if available)
|
| 75 |
+
authors = [a.strip() for a in authors_raw.split(";") if a.strip() and a.strip().lower() != "unknown"]
|
| 76 |
+
if authors:
|
| 77 |
+
parts.append(f"by {authors[0]}")
|
| 78 |
+
|
| 79 |
+
# Construct final sentence
|
| 80 |
+
highlight = " ".join(parts) + "."
|
| 81 |
+
|
| 82 |
+
# Handle author display
|
| 83 |
+
if authors and authors_raw.lower() != "unknown":
|
| 84 |
+
author_display = ", ".join(authors)
|
| 85 |
+
else:
|
| 86 |
+
author_display = "Unknown"
|
| 87 |
+
|
| 88 |
+
return {
|
| 89 |
+
"title": title,
|
| 90 |
+
"authors": author_display,
|
| 91 |
+
"category": category,
|
| 92 |
+
"description": desc,
|
| 93 |
+
"highlights": [highlight],
|
| 94 |
+
"persona_summary": persona.get("summary", ""),
|
| 95 |
+
"meta": {
|
| 96 |
+
"title": title,
|
| 97 |
+
"authors": author_display,
|
| 98 |
+
"category": category,
|
| 99 |
+
"description": desc
|
| 100 |
+
}
|
| 101 |
+
}
|
src/marketing/persona.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections import Counter
|
| 2 |
+
from typing import Dict, List, Any
|
| 3 |
+
import pandas as pd
|
| 4 |
+
|
| 5 |
+
from src.utils import setup_logger
|
| 6 |
+
|
| 7 |
+
logger = setup_logger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def build_persona(fav_isbns: List[str], books: pd.DataFrame) -> Dict[str, Any]:
|
| 11 |
+
"""Aggregate a simple persona from favorites: top authors and categories."""
|
| 12 |
+
if not isinstance(books, pd.DataFrame) or books.empty or not fav_isbns:
|
| 13 |
+
return {
|
| 14 |
+
"summary": "No profile yet. Start by adding your favorite books to see personalized recommendations.",
|
| 15 |
+
"top_authors": [],
|
| 16 |
+
"top_categories": [],
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
fav_df = books[books["isbn13"].astype(str).isin([str(x) for x in fav_isbns])]
|
| 20 |
+
authors_list: List[str] = []
|
| 21 |
+
categories_list: List[str] = []
|
| 22 |
+
|
| 23 |
+
for _, row in fav_df.iterrows():
|
| 24 |
+
# Authors are ';' separated in our dataset
|
| 25 |
+
try:
|
| 26 |
+
author_str = str(row.get("authors", "")).strip()
|
| 27 |
+
if author_str and author_str.lower() != "unknown":
|
| 28 |
+
authors_list.extend([a.strip() for a in author_str.split(";") if a.strip() and a.strip().lower() != "unknown"])
|
| 29 |
+
except Exception:
|
| 30 |
+
pass
|
| 31 |
+
cat = str(row.get("simple_categories", "")).strip()
|
| 32 |
+
if cat and cat.lower() != "unknown":
|
| 33 |
+
categories_list.append(cat)
|
| 34 |
+
|
| 35 |
+
top_authors = [a for a, _ in Counter(authors_list).most_common(3)]
|
| 36 |
+
top_categories = [c for c, _ in Counter(categories_list).most_common(3)]
|
| 37 |
+
|
| 38 |
+
if not top_authors and not top_categories:
|
| 39 |
+
return {
|
| 40 |
+
"summary": "Your profile is taking shape. Keep adding books to refine your taste profile.",
|
| 41 |
+
"top_authors": [],
|
| 42 |
+
"top_categories": [],
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
parts: List[str] = []
|
| 46 |
+
if top_authors:
|
| 47 |
+
parts.append(f"You love authors: {', '.join(top_authors)}")
|
| 48 |
+
if top_categories:
|
| 49 |
+
parts.append(f"You often read: {', '.join(top_categories)}")
|
| 50 |
+
|
| 51 |
+
return {
|
| 52 |
+
"summary": " | ".join(parts),
|
| 53 |
+
"top_authors": top_authors,
|
| 54 |
+
"top_categories": top_categories,
|
| 55 |
+
}
|
src/recommender.py
CHANGED
|
@@ -21,6 +21,7 @@ class BookRecommender:
|
|
| 21 |
def __init__(self) -> None:
|
| 22 |
"""Initialize the recommender by loading data and the vector database."""
|
| 23 |
self.books = load_books_data()
|
|
|
|
| 24 |
self.vector_db = VectorDB()
|
| 25 |
self.cache = CacheManager()
|
| 26 |
|
|
@@ -115,7 +116,25 @@ class BookRecommender:
|
|
| 115 |
authors_str = row["authors"]
|
| 116 |
|
| 117 |
# Fetch book cover in real-time from Google Books API
|
| 118 |
-
thumbnail = fetch_book_cover(str(row["isbn13"]), row["title"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
results.append({
|
| 121 |
"isbn": row["isbn13"],
|
|
@@ -123,8 +142,13 @@ class BookRecommender:
|
|
| 123 |
"authors": authors_str,
|
| 124 |
"description": truncated_desc,
|
| 125 |
"thumbnail": thumbnail,
|
| 126 |
-
"caption": f"{row['title']} by {authors_str}: {truncated_desc}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
})
|
|
|
|
| 128 |
return results
|
| 129 |
|
| 130 |
def get_categories(self) -> List[str]:
|
|
|
|
| 21 |
def __init__(self) -> None:
|
| 22 |
"""Initialize the recommender by loading data and the vector database."""
|
| 23 |
self.books = load_books_data()
|
| 24 |
+
logger.info(f"Loaded books DataFrame with columns: {self.books.columns.tolist()}")
|
| 25 |
self.vector_db = VectorDB()
|
| 26 |
self.cache = CacheManager()
|
| 27 |
|
|
|
|
| 116 |
authors_str = row["authors"]
|
| 117 |
|
| 118 |
# Fetch book cover in real-time from Google Books API
|
| 119 |
+
thumbnail, api_authors = fetch_book_cover(str(row["isbn13"]), row["title"])
|
| 120 |
+
authors_str = api_authors if api_authors != "Unknown" else authors_str
|
| 121 |
+
|
| 122 |
+
# Parse tags from semicolon-separated string
|
| 123 |
+
tags_raw = str(row.get("tags", "")).strip()
|
| 124 |
+
tags = [t.strip() for t in tags_raw.split(";") if t.strip()] if tags_raw else []
|
| 125 |
+
|
| 126 |
+
# Extract emotion scores
|
| 127 |
+
emotions = {
|
| 128 |
+
"joy": float(row.get("joy", 0.0)),
|
| 129 |
+
"sadness": float(row.get("sadness", 0.0)),
|
| 130 |
+
"fear": float(row.get("fear", 0.0)),
|
| 131 |
+
"anger": float(row.get("anger", 0.0)),
|
| 132 |
+
"surprise": float(row.get("surprise", 0.0)),
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
# Parse review highlights from semicolon-separated string
|
| 136 |
+
highlights_raw = str(row.get("review_highlights", "")).strip()
|
| 137 |
+
review_highlights = [h.strip() for h in highlights_raw.split(";") if h.strip()] if highlights_raw else []
|
| 138 |
|
| 139 |
results.append({
|
| 140 |
"isbn": row["isbn13"],
|
|
|
|
| 142 |
"authors": authors_str,
|
| 143 |
"description": truncated_desc,
|
| 144 |
"thumbnail": thumbnail,
|
| 145 |
+
"caption": f"{row['title']} by {authors_str}: {truncated_desc}",
|
| 146 |
+
"tags": tags,
|
| 147 |
+
"emotions": emotions,
|
| 148 |
+
"review_highlights": review_highlights,
|
| 149 |
+
"average_rating": float(row.get("average_rating", 0.0))
|
| 150 |
})
|
| 151 |
+
logger.info(f"Sample result: {results[0] if results else 'EMPTY'}")
|
| 152 |
return results
|
| 153 |
|
| 154 |
def get_categories(self) -> List[str]:
|
src/user/profile_store.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
from typing import Dict, List
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from src.utils import setup_logger
|
| 7 |
+
from src.config import DATA_DIR
|
| 8 |
+
|
| 9 |
+
logger = setup_logger(__name__)
|
| 10 |
+
|
| 11 |
+
STORE_PATH = DATA_DIR / "user_profiles.json"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _ensure_store_file() -> None:
|
| 15 |
+
try:
|
| 16 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
if not STORE_PATH.exists():
|
| 18 |
+
with open(STORE_PATH, "w", encoding="utf-8") as f:
|
| 19 |
+
json.dump({}, f)
|
| 20 |
+
except Exception as e:
|
| 21 |
+
logger.error(f"Failed to ensure profile store: {e}")
|
| 22 |
+
raise
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _load_store() -> Dict[str, Dict[str, List[str]]]:
|
| 26 |
+
_ensure_store_file()
|
| 27 |
+
with open(STORE_PATH, "r", encoding="utf-8") as f:
|
| 28 |
+
try:
|
| 29 |
+
data = json.load(f)
|
| 30 |
+
if not isinstance(data, dict):
|
| 31 |
+
return {}
|
| 32 |
+
return data
|
| 33 |
+
except json.JSONDecodeError:
|
| 34 |
+
return {}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _save_store(store: Dict[str, Dict[str, List[str]]]) -> None:
|
| 38 |
+
with open(STORE_PATH, "w", encoding="utf-8") as f:
|
| 39 |
+
json.dump(store, f, ensure_ascii=False, indent=2)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def add_favorite(user_id: str, isbn: str) -> int:
|
| 43 |
+
"""Add an ISBN to user's favorites. Returns new favorites count."""
|
| 44 |
+
store = _load_store()
|
| 45 |
+
user = store.get(user_id) or {"favorites": []}
|
| 46 |
+
favs: List[str] = list(dict.fromkeys(user.get("favorites", [])))
|
| 47 |
+
if isbn not in favs:
|
| 48 |
+
favs.append(isbn)
|
| 49 |
+
user["favorites"] = favs
|
| 50 |
+
store[user_id] = user
|
| 51 |
+
_save_store(store)
|
| 52 |
+
return len(favs)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def list_favorites(user_id: str) -> List[str]:
|
| 56 |
+
store = _load_store()
|
| 57 |
+
user = store.get(user_id) or {"favorites": []}
|
| 58 |
+
favs: List[str] = user.get("favorites", [])
|
| 59 |
+
# De-duplicate while preserving order
|
| 60 |
+
return list(dict.fromkeys(favs))
|
web/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 纸间留白 · 私人书斋(前端)
|
| 2 |
+
|
| 3 |
+
React + Vite 前端,使用 Tailwind CDN 与 lucide-react 图标,连接现有 FastAPI 后端。
|
| 4 |
+
|
| 5 |
+
## 开发
|
| 6 |
+
|
| 7 |
+
1. 安装依赖:
|
| 8 |
+
|
| 9 |
+
```bash
|
| 10 |
+
cd web
|
| 11 |
+
npm install
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
2. 本地开发:
|
| 15 |
+
|
| 16 |
+
```bash
|
| 17 |
+
npm run dev
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
默认端口 `5173`。后端需运行在 `http://localhost:6006`。
|
| 21 |
+
|
| 22 |
+
如需修改后端地址,创建 `.env` 并设置:
|
| 23 |
+
|
| 24 |
+
```
|
| 25 |
+
VITE_API_URL=http://localhost:6006
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
## 后端接口
|
| 29 |
+
- `POST /recommend` { query, category, tone } → 书籍列表
|
| 30 |
+
- `POST /favorites/add` { isbn, user_id } → 收藏计数
|
| 31 |
+
- `GET /user/{user_id}/persona` → 用户画像
|
| 32 |
+
- `POST /marketing/highlights` { isbn, user_id } → 个性化卖点
|
| 33 |
+
|
| 34 |
+
## 注意
|
| 35 |
+
- 已在后端添加 CORS 允许 `http://localhost:5173`。
|
| 36 |
+
- 初版将中文分类/心境映射到后端 `category/tone`,可根据数据进一步细化。
|
web/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>纸间留白 · 私人书斋</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
web/package-lock.json
ADDED
|
@@ -0,0 +1,1054 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "paper-shelf-ui",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"lockfileVersion": 3,
|
| 5 |
+
"requires": true,
|
| 6 |
+
"packages": {
|
| 7 |
+
"": {
|
| 8 |
+
"name": "paper-shelf-ui",
|
| 9 |
+
"version": "0.1.0",
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"lucide-react": "^0.446.0",
|
| 12 |
+
"react": "^18.2.0",
|
| 13 |
+
"react-dom": "^18.2.0"
|
| 14 |
+
},
|
| 15 |
+
"devDependencies": {
|
| 16 |
+
"vite": "^5.0.0"
|
| 17 |
+
}
|
| 18 |
+
},
|
| 19 |
+
"node_modules/@esbuild/aix-ppc64": {
|
| 20 |
+
"version": "0.21.5",
|
| 21 |
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
| 22 |
+
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
| 23 |
+
"cpu": [
|
| 24 |
+
"ppc64"
|
| 25 |
+
],
|
| 26 |
+
"dev": true,
|
| 27 |
+
"license": "MIT",
|
| 28 |
+
"optional": true,
|
| 29 |
+
"os": [
|
| 30 |
+
"aix"
|
| 31 |
+
],
|
| 32 |
+
"engines": {
|
| 33 |
+
"node": ">=12"
|
| 34 |
+
}
|
| 35 |
+
},
|
| 36 |
+
"node_modules/@esbuild/android-arm": {
|
| 37 |
+
"version": "0.21.5",
|
| 38 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
| 39 |
+
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
| 40 |
+
"cpu": [
|
| 41 |
+
"arm"
|
| 42 |
+
],
|
| 43 |
+
"dev": true,
|
| 44 |
+
"license": "MIT",
|
| 45 |
+
"optional": true,
|
| 46 |
+
"os": [
|
| 47 |
+
"android"
|
| 48 |
+
],
|
| 49 |
+
"engines": {
|
| 50 |
+
"node": ">=12"
|
| 51 |
+
}
|
| 52 |
+
},
|
| 53 |
+
"node_modules/@esbuild/android-arm64": {
|
| 54 |
+
"version": "0.21.5",
|
| 55 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
| 56 |
+
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
| 57 |
+
"cpu": [
|
| 58 |
+
"arm64"
|
| 59 |
+
],
|
| 60 |
+
"dev": true,
|
| 61 |
+
"license": "MIT",
|
| 62 |
+
"optional": true,
|
| 63 |
+
"os": [
|
| 64 |
+
"android"
|
| 65 |
+
],
|
| 66 |
+
"engines": {
|
| 67 |
+
"node": ">=12"
|
| 68 |
+
}
|
| 69 |
+
},
|
| 70 |
+
"node_modules/@esbuild/android-x64": {
|
| 71 |
+
"version": "0.21.5",
|
| 72 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
| 73 |
+
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
| 74 |
+
"cpu": [
|
| 75 |
+
"x64"
|
| 76 |
+
],
|
| 77 |
+
"dev": true,
|
| 78 |
+
"license": "MIT",
|
| 79 |
+
"optional": true,
|
| 80 |
+
"os": [
|
| 81 |
+
"android"
|
| 82 |
+
],
|
| 83 |
+
"engines": {
|
| 84 |
+
"node": ">=12"
|
| 85 |
+
}
|
| 86 |
+
},
|
| 87 |
+
"node_modules/@esbuild/darwin-arm64": {
|
| 88 |
+
"version": "0.21.5",
|
| 89 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
| 90 |
+
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
| 91 |
+
"cpu": [
|
| 92 |
+
"arm64"
|
| 93 |
+
],
|
| 94 |
+
"dev": true,
|
| 95 |
+
"license": "MIT",
|
| 96 |
+
"optional": true,
|
| 97 |
+
"os": [
|
| 98 |
+
"darwin"
|
| 99 |
+
],
|
| 100 |
+
"engines": {
|
| 101 |
+
"node": ">=12"
|
| 102 |
+
}
|
| 103 |
+
},
|
| 104 |
+
"node_modules/@esbuild/darwin-x64": {
|
| 105 |
+
"version": "0.21.5",
|
| 106 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
| 107 |
+
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
| 108 |
+
"cpu": [
|
| 109 |
+
"x64"
|
| 110 |
+
],
|
| 111 |
+
"dev": true,
|
| 112 |
+
"license": "MIT",
|
| 113 |
+
"optional": true,
|
| 114 |
+
"os": [
|
| 115 |
+
"darwin"
|
| 116 |
+
],
|
| 117 |
+
"engines": {
|
| 118 |
+
"node": ">=12"
|
| 119 |
+
}
|
| 120 |
+
},
|
| 121 |
+
"node_modules/@esbuild/freebsd-arm64": {
|
| 122 |
+
"version": "0.21.5",
|
| 123 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
| 124 |
+
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
| 125 |
+
"cpu": [
|
| 126 |
+
"arm64"
|
| 127 |
+
],
|
| 128 |
+
"dev": true,
|
| 129 |
+
"license": "MIT",
|
| 130 |
+
"optional": true,
|
| 131 |
+
"os": [
|
| 132 |
+
"freebsd"
|
| 133 |
+
],
|
| 134 |
+
"engines": {
|
| 135 |
+
"node": ">=12"
|
| 136 |
+
}
|
| 137 |
+
},
|
| 138 |
+
"node_modules/@esbuild/freebsd-x64": {
|
| 139 |
+
"version": "0.21.5",
|
| 140 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
| 141 |
+
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
| 142 |
+
"cpu": [
|
| 143 |
+
"x64"
|
| 144 |
+
],
|
| 145 |
+
"dev": true,
|
| 146 |
+
"license": "MIT",
|
| 147 |
+
"optional": true,
|
| 148 |
+
"os": [
|
| 149 |
+
"freebsd"
|
| 150 |
+
],
|
| 151 |
+
"engines": {
|
| 152 |
+
"node": ">=12"
|
| 153 |
+
}
|
| 154 |
+
},
|
| 155 |
+
"node_modules/@esbuild/linux-arm": {
|
| 156 |
+
"version": "0.21.5",
|
| 157 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
| 158 |
+
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
| 159 |
+
"cpu": [
|
| 160 |
+
"arm"
|
| 161 |
+
],
|
| 162 |
+
"dev": true,
|
| 163 |
+
"license": "MIT",
|
| 164 |
+
"optional": true,
|
| 165 |
+
"os": [
|
| 166 |
+
"linux"
|
| 167 |
+
],
|
| 168 |
+
"engines": {
|
| 169 |
+
"node": ">=12"
|
| 170 |
+
}
|
| 171 |
+
},
|
| 172 |
+
"node_modules/@esbuild/linux-arm64": {
|
| 173 |
+
"version": "0.21.5",
|
| 174 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
| 175 |
+
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
| 176 |
+
"cpu": [
|
| 177 |
+
"arm64"
|
| 178 |
+
],
|
| 179 |
+
"dev": true,
|
| 180 |
+
"license": "MIT",
|
| 181 |
+
"optional": true,
|
| 182 |
+
"os": [
|
| 183 |
+
"linux"
|
| 184 |
+
],
|
| 185 |
+
"engines": {
|
| 186 |
+
"node": ">=12"
|
| 187 |
+
}
|
| 188 |
+
},
|
| 189 |
+
"node_modules/@esbuild/linux-ia32": {
|
| 190 |
+
"version": "0.21.5",
|
| 191 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
| 192 |
+
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
| 193 |
+
"cpu": [
|
| 194 |
+
"ia32"
|
| 195 |
+
],
|
| 196 |
+
"dev": true,
|
| 197 |
+
"license": "MIT",
|
| 198 |
+
"optional": true,
|
| 199 |
+
"os": [
|
| 200 |
+
"linux"
|
| 201 |
+
],
|
| 202 |
+
"engines": {
|
| 203 |
+
"node": ">=12"
|
| 204 |
+
}
|
| 205 |
+
},
|
| 206 |
+
"node_modules/@esbuild/linux-loong64": {
|
| 207 |
+
"version": "0.21.5",
|
| 208 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
| 209 |
+
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
| 210 |
+
"cpu": [
|
| 211 |
+
"loong64"
|
| 212 |
+
],
|
| 213 |
+
"dev": true,
|
| 214 |
+
"license": "MIT",
|
| 215 |
+
"optional": true,
|
| 216 |
+
"os": [
|
| 217 |
+
"linux"
|
| 218 |
+
],
|
| 219 |
+
"engines": {
|
| 220 |
+
"node": ">=12"
|
| 221 |
+
}
|
| 222 |
+
},
|
| 223 |
+
"node_modules/@esbuild/linux-mips64el": {
|
| 224 |
+
"version": "0.21.5",
|
| 225 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
| 226 |
+
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
| 227 |
+
"cpu": [
|
| 228 |
+
"mips64el"
|
| 229 |
+
],
|
| 230 |
+
"dev": true,
|
| 231 |
+
"license": "MIT",
|
| 232 |
+
"optional": true,
|
| 233 |
+
"os": [
|
| 234 |
+
"linux"
|
| 235 |
+
],
|
| 236 |
+
"engines": {
|
| 237 |
+
"node": ">=12"
|
| 238 |
+
}
|
| 239 |
+
},
|
| 240 |
+
"node_modules/@esbuild/linux-ppc64": {
|
| 241 |
+
"version": "0.21.5",
|
| 242 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
| 243 |
+
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
| 244 |
+
"cpu": [
|
| 245 |
+
"ppc64"
|
| 246 |
+
],
|
| 247 |
+
"dev": true,
|
| 248 |
+
"license": "MIT",
|
| 249 |
+
"optional": true,
|
| 250 |
+
"os": [
|
| 251 |
+
"linux"
|
| 252 |
+
],
|
| 253 |
+
"engines": {
|
| 254 |
+
"node": ">=12"
|
| 255 |
+
}
|
| 256 |
+
},
|
| 257 |
+
"node_modules/@esbuild/linux-riscv64": {
|
| 258 |
+
"version": "0.21.5",
|
| 259 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
| 260 |
+
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
| 261 |
+
"cpu": [
|
| 262 |
+
"riscv64"
|
| 263 |
+
],
|
| 264 |
+
"dev": true,
|
| 265 |
+
"license": "MIT",
|
| 266 |
+
"optional": true,
|
| 267 |
+
"os": [
|
| 268 |
+
"linux"
|
| 269 |
+
],
|
| 270 |
+
"engines": {
|
| 271 |
+
"node": ">=12"
|
| 272 |
+
}
|
| 273 |
+
},
|
| 274 |
+
"node_modules/@esbuild/linux-s390x": {
|
| 275 |
+
"version": "0.21.5",
|
| 276 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
| 277 |
+
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
| 278 |
+
"cpu": [
|
| 279 |
+
"s390x"
|
| 280 |
+
],
|
| 281 |
+
"dev": true,
|
| 282 |
+
"license": "MIT",
|
| 283 |
+
"optional": true,
|
| 284 |
+
"os": [
|
| 285 |
+
"linux"
|
| 286 |
+
],
|
| 287 |
+
"engines": {
|
| 288 |
+
"node": ">=12"
|
| 289 |
+
}
|
| 290 |
+
},
|
| 291 |
+
"node_modules/@esbuild/linux-x64": {
|
| 292 |
+
"version": "0.21.5",
|
| 293 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
| 294 |
+
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
| 295 |
+
"cpu": [
|
| 296 |
+
"x64"
|
| 297 |
+
],
|
| 298 |
+
"dev": true,
|
| 299 |
+
"license": "MIT",
|
| 300 |
+
"optional": true,
|
| 301 |
+
"os": [
|
| 302 |
+
"linux"
|
| 303 |
+
],
|
| 304 |
+
"engines": {
|
| 305 |
+
"node": ">=12"
|
| 306 |
+
}
|
| 307 |
+
},
|
| 308 |
+
"node_modules/@esbuild/netbsd-x64": {
|
| 309 |
+
"version": "0.21.5",
|
| 310 |
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
| 311 |
+
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
| 312 |
+
"cpu": [
|
| 313 |
+
"x64"
|
| 314 |
+
],
|
| 315 |
+
"dev": true,
|
| 316 |
+
"license": "MIT",
|
| 317 |
+
"optional": true,
|
| 318 |
+
"os": [
|
| 319 |
+
"netbsd"
|
| 320 |
+
],
|
| 321 |
+
"engines": {
|
| 322 |
+
"node": ">=12"
|
| 323 |
+
}
|
| 324 |
+
},
|
| 325 |
+
"node_modules/@esbuild/openbsd-x64": {
|
| 326 |
+
"version": "0.21.5",
|
| 327 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
| 328 |
+
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
| 329 |
+
"cpu": [
|
| 330 |
+
"x64"
|
| 331 |
+
],
|
| 332 |
+
"dev": true,
|
| 333 |
+
"license": "MIT",
|
| 334 |
+
"optional": true,
|
| 335 |
+
"os": [
|
| 336 |
+
"openbsd"
|
| 337 |
+
],
|
| 338 |
+
"engines": {
|
| 339 |
+
"node": ">=12"
|
| 340 |
+
}
|
| 341 |
+
},
|
| 342 |
+
"node_modules/@esbuild/sunos-x64": {
|
| 343 |
+
"version": "0.21.5",
|
| 344 |
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
| 345 |
+
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
| 346 |
+
"cpu": [
|
| 347 |
+
"x64"
|
| 348 |
+
],
|
| 349 |
+
"dev": true,
|
| 350 |
+
"license": "MIT",
|
| 351 |
+
"optional": true,
|
| 352 |
+
"os": [
|
| 353 |
+
"sunos"
|
| 354 |
+
],
|
| 355 |
+
"engines": {
|
| 356 |
+
"node": ">=12"
|
| 357 |
+
}
|
| 358 |
+
},
|
| 359 |
+
"node_modules/@esbuild/win32-arm64": {
|
| 360 |
+
"version": "0.21.5",
|
| 361 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
| 362 |
+
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
| 363 |
+
"cpu": [
|
| 364 |
+
"arm64"
|
| 365 |
+
],
|
| 366 |
+
"dev": true,
|
| 367 |
+
"license": "MIT",
|
| 368 |
+
"optional": true,
|
| 369 |
+
"os": [
|
| 370 |
+
"win32"
|
| 371 |
+
],
|
| 372 |
+
"engines": {
|
| 373 |
+
"node": ">=12"
|
| 374 |
+
}
|
| 375 |
+
},
|
| 376 |
+
"node_modules/@esbuild/win32-ia32": {
|
| 377 |
+
"version": "0.21.5",
|
| 378 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
| 379 |
+
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
| 380 |
+
"cpu": [
|
| 381 |
+
"ia32"
|
| 382 |
+
],
|
| 383 |
+
"dev": true,
|
| 384 |
+
"license": "MIT",
|
| 385 |
+
"optional": true,
|
| 386 |
+
"os": [
|
| 387 |
+
"win32"
|
| 388 |
+
],
|
| 389 |
+
"engines": {
|
| 390 |
+
"node": ">=12"
|
| 391 |
+
}
|
| 392 |
+
},
|
| 393 |
+
"node_modules/@esbuild/win32-x64": {
|
| 394 |
+
"version": "0.21.5",
|
| 395 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
| 396 |
+
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
| 397 |
+
"cpu": [
|
| 398 |
+
"x64"
|
| 399 |
+
],
|
| 400 |
+
"dev": true,
|
| 401 |
+
"license": "MIT",
|
| 402 |
+
"optional": true,
|
| 403 |
+
"os": [
|
| 404 |
+
"win32"
|
| 405 |
+
],
|
| 406 |
+
"engines": {
|
| 407 |
+
"node": ">=12"
|
| 408 |
+
}
|
| 409 |
+
},
|
| 410 |
+
"node_modules/@rollup/rollup-android-arm-eabi": {
|
| 411 |
+
"version": "4.55.1",
|
| 412 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
|
| 413 |
+
"integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
|
| 414 |
+
"cpu": [
|
| 415 |
+
"arm"
|
| 416 |
+
],
|
| 417 |
+
"dev": true,
|
| 418 |
+
"license": "MIT",
|
| 419 |
+
"optional": true,
|
| 420 |
+
"os": [
|
| 421 |
+
"android"
|
| 422 |
+
]
|
| 423 |
+
},
|
| 424 |
+
"node_modules/@rollup/rollup-android-arm64": {
|
| 425 |
+
"version": "4.55.1",
|
| 426 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
|
| 427 |
+
"integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
|
| 428 |
+
"cpu": [
|
| 429 |
+
"arm64"
|
| 430 |
+
],
|
| 431 |
+
"dev": true,
|
| 432 |
+
"license": "MIT",
|
| 433 |
+
"optional": true,
|
| 434 |
+
"os": [
|
| 435 |
+
"android"
|
| 436 |
+
]
|
| 437 |
+
},
|
| 438 |
+
"node_modules/@rollup/rollup-darwin-arm64": {
|
| 439 |
+
"version": "4.55.1",
|
| 440 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
|
| 441 |
+
"integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
|
| 442 |
+
"cpu": [
|
| 443 |
+
"arm64"
|
| 444 |
+
],
|
| 445 |
+
"dev": true,
|
| 446 |
+
"license": "MIT",
|
| 447 |
+
"optional": true,
|
| 448 |
+
"os": [
|
| 449 |
+
"darwin"
|
| 450 |
+
]
|
| 451 |
+
},
|
| 452 |
+
"node_modules/@rollup/rollup-darwin-x64": {
|
| 453 |
+
"version": "4.55.1",
|
| 454 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
|
| 455 |
+
"integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
|
| 456 |
+
"cpu": [
|
| 457 |
+
"x64"
|
| 458 |
+
],
|
| 459 |
+
"dev": true,
|
| 460 |
+
"license": "MIT",
|
| 461 |
+
"optional": true,
|
| 462 |
+
"os": [
|
| 463 |
+
"darwin"
|
| 464 |
+
]
|
| 465 |
+
},
|
| 466 |
+
"node_modules/@rollup/rollup-freebsd-arm64": {
|
| 467 |
+
"version": "4.55.1",
|
| 468 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
|
| 469 |
+
"integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
|
| 470 |
+
"cpu": [
|
| 471 |
+
"arm64"
|
| 472 |
+
],
|
| 473 |
+
"dev": true,
|
| 474 |
+
"license": "MIT",
|
| 475 |
+
"optional": true,
|
| 476 |
+
"os": [
|
| 477 |
+
"freebsd"
|
| 478 |
+
]
|
| 479 |
+
},
|
| 480 |
+
"node_modules/@rollup/rollup-freebsd-x64": {
|
| 481 |
+
"version": "4.55.1",
|
| 482 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
|
| 483 |
+
"integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
|
| 484 |
+
"cpu": [
|
| 485 |
+
"x64"
|
| 486 |
+
],
|
| 487 |
+
"dev": true,
|
| 488 |
+
"license": "MIT",
|
| 489 |
+
"optional": true,
|
| 490 |
+
"os": [
|
| 491 |
+
"freebsd"
|
| 492 |
+
]
|
| 493 |
+
},
|
| 494 |
+
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
| 495 |
+
"version": "4.55.1",
|
| 496 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
|
| 497 |
+
"integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
|
| 498 |
+
"cpu": [
|
| 499 |
+
"arm"
|
| 500 |
+
],
|
| 501 |
+
"dev": true,
|
| 502 |
+
"license": "MIT",
|
| 503 |
+
"optional": true,
|
| 504 |
+
"os": [
|
| 505 |
+
"linux"
|
| 506 |
+
]
|
| 507 |
+
},
|
| 508 |
+
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
| 509 |
+
"version": "4.55.1",
|
| 510 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
|
| 511 |
+
"integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
|
| 512 |
+
"cpu": [
|
| 513 |
+
"arm"
|
| 514 |
+
],
|
| 515 |
+
"dev": true,
|
| 516 |
+
"license": "MIT",
|
| 517 |
+
"optional": true,
|
| 518 |
+
"os": [
|
| 519 |
+
"linux"
|
| 520 |
+
]
|
| 521 |
+
},
|
| 522 |
+
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
| 523 |
+
"version": "4.55.1",
|
| 524 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
|
| 525 |
+
"integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
|
| 526 |
+
"cpu": [
|
| 527 |
+
"arm64"
|
| 528 |
+
],
|
| 529 |
+
"dev": true,
|
| 530 |
+
"license": "MIT",
|
| 531 |
+
"optional": true,
|
| 532 |
+
"os": [
|
| 533 |
+
"linux"
|
| 534 |
+
]
|
| 535 |
+
},
|
| 536 |
+
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
| 537 |
+
"version": "4.55.1",
|
| 538 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
|
| 539 |
+
"integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
|
| 540 |
+
"cpu": [
|
| 541 |
+
"arm64"
|
| 542 |
+
],
|
| 543 |
+
"dev": true,
|
| 544 |
+
"license": "MIT",
|
| 545 |
+
"optional": true,
|
| 546 |
+
"os": [
|
| 547 |
+
"linux"
|
| 548 |
+
]
|
| 549 |
+
},
|
| 550 |
+
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
| 551 |
+
"version": "4.55.1",
|
| 552 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
|
| 553 |
+
"integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
|
| 554 |
+
"cpu": [
|
| 555 |
+
"loong64"
|
| 556 |
+
],
|
| 557 |
+
"dev": true,
|
| 558 |
+
"license": "MIT",
|
| 559 |
+
"optional": true,
|
| 560 |
+
"os": [
|
| 561 |
+
"linux"
|
| 562 |
+
]
|
| 563 |
+
},
|
| 564 |
+
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
| 565 |
+
"version": "4.55.1",
|
| 566 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
|
| 567 |
+
"integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
|
| 568 |
+
"cpu": [
|
| 569 |
+
"loong64"
|
| 570 |
+
],
|
| 571 |
+
"dev": true,
|
| 572 |
+
"license": "MIT",
|
| 573 |
+
"optional": true,
|
| 574 |
+
"os": [
|
| 575 |
+
"linux"
|
| 576 |
+
]
|
| 577 |
+
},
|
| 578 |
+
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
| 579 |
+
"version": "4.55.1",
|
| 580 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
|
| 581 |
+
"integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
|
| 582 |
+
"cpu": [
|
| 583 |
+
"ppc64"
|
| 584 |
+
],
|
| 585 |
+
"dev": true,
|
| 586 |
+
"license": "MIT",
|
| 587 |
+
"optional": true,
|
| 588 |
+
"os": [
|
| 589 |
+
"linux"
|
| 590 |
+
]
|
| 591 |
+
},
|
| 592 |
+
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
| 593 |
+
"version": "4.55.1",
|
| 594 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
|
| 595 |
+
"integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
|
| 596 |
+
"cpu": [
|
| 597 |
+
"ppc64"
|
| 598 |
+
],
|
| 599 |
+
"dev": true,
|
| 600 |
+
"license": "MIT",
|
| 601 |
+
"optional": true,
|
| 602 |
+
"os": [
|
| 603 |
+
"linux"
|
| 604 |
+
]
|
| 605 |
+
},
|
| 606 |
+
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
| 607 |
+
"version": "4.55.1",
|
| 608 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
|
| 609 |
+
"integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
|
| 610 |
+
"cpu": [
|
| 611 |
+
"riscv64"
|
| 612 |
+
],
|
| 613 |
+
"dev": true,
|
| 614 |
+
"license": "MIT",
|
| 615 |
+
"optional": true,
|
| 616 |
+
"os": [
|
| 617 |
+
"linux"
|
| 618 |
+
]
|
| 619 |
+
},
|
| 620 |
+
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
| 621 |
+
"version": "4.55.1",
|
| 622 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
|
| 623 |
+
"integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
|
| 624 |
+
"cpu": [
|
| 625 |
+
"riscv64"
|
| 626 |
+
],
|
| 627 |
+
"dev": true,
|
| 628 |
+
"license": "MIT",
|
| 629 |
+
"optional": true,
|
| 630 |
+
"os": [
|
| 631 |
+
"linux"
|
| 632 |
+
]
|
| 633 |
+
},
|
| 634 |
+
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
| 635 |
+
"version": "4.55.1",
|
| 636 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
|
| 637 |
+
"integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
|
| 638 |
+
"cpu": [
|
| 639 |
+
"s390x"
|
| 640 |
+
],
|
| 641 |
+
"dev": true,
|
| 642 |
+
"license": "MIT",
|
| 643 |
+
"optional": true,
|
| 644 |
+
"os": [
|
| 645 |
+
"linux"
|
| 646 |
+
]
|
| 647 |
+
},
|
| 648 |
+
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
| 649 |
+
"version": "4.55.1",
|
| 650 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
|
| 651 |
+
"integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
|
| 652 |
+
"cpu": [
|
| 653 |
+
"x64"
|
| 654 |
+
],
|
| 655 |
+
"dev": true,
|
| 656 |
+
"license": "MIT",
|
| 657 |
+
"optional": true,
|
| 658 |
+
"os": [
|
| 659 |
+
"linux"
|
| 660 |
+
]
|
| 661 |
+
},
|
| 662 |
+
"node_modules/@rollup/rollup-linux-x64-musl": {
|
| 663 |
+
"version": "4.55.1",
|
| 664 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
|
| 665 |
+
"integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
|
| 666 |
+
"cpu": [
|
| 667 |
+
"x64"
|
| 668 |
+
],
|
| 669 |
+
"dev": true,
|
| 670 |
+
"license": "MIT",
|
| 671 |
+
"optional": true,
|
| 672 |
+
"os": [
|
| 673 |
+
"linux"
|
| 674 |
+
]
|
| 675 |
+
},
|
| 676 |
+
"node_modules/@rollup/rollup-openbsd-x64": {
|
| 677 |
+
"version": "4.55.1",
|
| 678 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
|
| 679 |
+
"integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
|
| 680 |
+
"cpu": [
|
| 681 |
+
"x64"
|
| 682 |
+
],
|
| 683 |
+
"dev": true,
|
| 684 |
+
"license": "MIT",
|
| 685 |
+
"optional": true,
|
| 686 |
+
"os": [
|
| 687 |
+
"openbsd"
|
| 688 |
+
]
|
| 689 |
+
},
|
| 690 |
+
"node_modules/@rollup/rollup-openharmony-arm64": {
|
| 691 |
+
"version": "4.55.1",
|
| 692 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
|
| 693 |
+
"integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
|
| 694 |
+
"cpu": [
|
| 695 |
+
"arm64"
|
| 696 |
+
],
|
| 697 |
+
"dev": true,
|
| 698 |
+
"license": "MIT",
|
| 699 |
+
"optional": true,
|
| 700 |
+
"os": [
|
| 701 |
+
"openharmony"
|
| 702 |
+
]
|
| 703 |
+
},
|
| 704 |
+
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
| 705 |
+
"version": "4.55.1",
|
| 706 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
|
| 707 |
+
"integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
|
| 708 |
+
"cpu": [
|
| 709 |
+
"arm64"
|
| 710 |
+
],
|
| 711 |
+
"dev": true,
|
| 712 |
+
"license": "MIT",
|
| 713 |
+
"optional": true,
|
| 714 |
+
"os": [
|
| 715 |
+
"win32"
|
| 716 |
+
]
|
| 717 |
+
},
|
| 718 |
+
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
| 719 |
+
"version": "4.55.1",
|
| 720 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
|
| 721 |
+
"integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
|
| 722 |
+
"cpu": [
|
| 723 |
+
"ia32"
|
| 724 |
+
],
|
| 725 |
+
"dev": true,
|
| 726 |
+
"license": "MIT",
|
| 727 |
+
"optional": true,
|
| 728 |
+
"os": [
|
| 729 |
+
"win32"
|
| 730 |
+
]
|
| 731 |
+
},
|
| 732 |
+
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
| 733 |
+
"version": "4.55.1",
|
| 734 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
|
| 735 |
+
"integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
|
| 736 |
+
"cpu": [
|
| 737 |
+
"x64"
|
| 738 |
+
],
|
| 739 |
+
"dev": true,
|
| 740 |
+
"license": "MIT",
|
| 741 |
+
"optional": true,
|
| 742 |
+
"os": [
|
| 743 |
+
"win32"
|
| 744 |
+
]
|
| 745 |
+
},
|
| 746 |
+
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
| 747 |
+
"version": "4.55.1",
|
| 748 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
|
| 749 |
+
"integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
|
| 750 |
+
"cpu": [
|
| 751 |
+
"x64"
|
| 752 |
+
],
|
| 753 |
+
"dev": true,
|
| 754 |
+
"license": "MIT",
|
| 755 |
+
"optional": true,
|
| 756 |
+
"os": [
|
| 757 |
+
"win32"
|
| 758 |
+
]
|
| 759 |
+
},
|
| 760 |
+
"node_modules/@types/estree": {
|
| 761 |
+
"version": "1.0.8",
|
| 762 |
+
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
| 763 |
+
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
| 764 |
+
"dev": true,
|
| 765 |
+
"license": "MIT"
|
| 766 |
+
},
|
| 767 |
+
"node_modules/esbuild": {
|
| 768 |
+
"version": "0.21.5",
|
| 769 |
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
| 770 |
+
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
| 771 |
+
"dev": true,
|
| 772 |
+
"hasInstallScript": true,
|
| 773 |
+
"license": "MIT",
|
| 774 |
+
"bin": {
|
| 775 |
+
"esbuild": "bin/esbuild"
|
| 776 |
+
},
|
| 777 |
+
"engines": {
|
| 778 |
+
"node": ">=12"
|
| 779 |
+
},
|
| 780 |
+
"optionalDependencies": {
|
| 781 |
+
"@esbuild/aix-ppc64": "0.21.5",
|
| 782 |
+
"@esbuild/android-arm": "0.21.5",
|
| 783 |
+
"@esbuild/android-arm64": "0.21.5",
|
| 784 |
+
"@esbuild/android-x64": "0.21.5",
|
| 785 |
+
"@esbuild/darwin-arm64": "0.21.5",
|
| 786 |
+
"@esbuild/darwin-x64": "0.21.5",
|
| 787 |
+
"@esbuild/freebsd-arm64": "0.21.5",
|
| 788 |
+
"@esbuild/freebsd-x64": "0.21.5",
|
| 789 |
+
"@esbuild/linux-arm": "0.21.5",
|
| 790 |
+
"@esbuild/linux-arm64": "0.21.5",
|
| 791 |
+
"@esbuild/linux-ia32": "0.21.5",
|
| 792 |
+
"@esbuild/linux-loong64": "0.21.5",
|
| 793 |
+
"@esbuild/linux-mips64el": "0.21.5",
|
| 794 |
+
"@esbuild/linux-ppc64": "0.21.5",
|
| 795 |
+
"@esbuild/linux-riscv64": "0.21.5",
|
| 796 |
+
"@esbuild/linux-s390x": "0.21.5",
|
| 797 |
+
"@esbuild/linux-x64": "0.21.5",
|
| 798 |
+
"@esbuild/netbsd-x64": "0.21.5",
|
| 799 |
+
"@esbuild/openbsd-x64": "0.21.5",
|
| 800 |
+
"@esbuild/sunos-x64": "0.21.5",
|
| 801 |
+
"@esbuild/win32-arm64": "0.21.5",
|
| 802 |
+
"@esbuild/win32-ia32": "0.21.5",
|
| 803 |
+
"@esbuild/win32-x64": "0.21.5"
|
| 804 |
+
}
|
| 805 |
+
},
|
| 806 |
+
"node_modules/fsevents": {
|
| 807 |
+
"version": "2.3.3",
|
| 808 |
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
| 809 |
+
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
| 810 |
+
"dev": true,
|
| 811 |
+
"hasInstallScript": true,
|
| 812 |
+
"license": "MIT",
|
| 813 |
+
"optional": true,
|
| 814 |
+
"os": [
|
| 815 |
+
"darwin"
|
| 816 |
+
],
|
| 817 |
+
"engines": {
|
| 818 |
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
| 819 |
+
}
|
| 820 |
+
},
|
| 821 |
+
"node_modules/js-tokens": {
|
| 822 |
+
"version": "4.0.0",
|
| 823 |
+
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
| 824 |
+
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
| 825 |
+
"license": "MIT"
|
| 826 |
+
},
|
| 827 |
+
"node_modules/loose-envify": {
|
| 828 |
+
"version": "1.4.0",
|
| 829 |
+
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
| 830 |
+
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
| 831 |
+
"license": "MIT",
|
| 832 |
+
"dependencies": {
|
| 833 |
+
"js-tokens": "^3.0.0 || ^4.0.0"
|
| 834 |
+
},
|
| 835 |
+
"bin": {
|
| 836 |
+
"loose-envify": "cli.js"
|
| 837 |
+
}
|
| 838 |
+
},
|
| 839 |
+
"node_modules/lucide-react": {
|
| 840 |
+
"version": "0.446.0",
|
| 841 |
+
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.446.0.tgz",
|
| 842 |
+
"integrity": "sha512-BU7gy8MfBMqvEdDPH79VhOXSEgyG8TSPOKWaExWGCQVqnGH7wGgDngPbofu+KdtVjPQBWbEmnfMTq90CTiiDRg==",
|
| 843 |
+
"license": "ISC",
|
| 844 |
+
"peerDependencies": {
|
| 845 |
+
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
| 846 |
+
}
|
| 847 |
+
},
|
| 848 |
+
"node_modules/nanoid": {
|
| 849 |
+
"version": "3.3.11",
|
| 850 |
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
| 851 |
+
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
| 852 |
+
"dev": true,
|
| 853 |
+
"funding": [
|
| 854 |
+
{
|
| 855 |
+
"type": "github",
|
| 856 |
+
"url": "https://github.com/sponsors/ai"
|
| 857 |
+
}
|
| 858 |
+
],
|
| 859 |
+
"license": "MIT",
|
| 860 |
+
"bin": {
|
| 861 |
+
"nanoid": "bin/nanoid.cjs"
|
| 862 |
+
},
|
| 863 |
+
"engines": {
|
| 864 |
+
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
| 865 |
+
}
|
| 866 |
+
},
|
| 867 |
+
"node_modules/picocolors": {
|
| 868 |
+
"version": "1.1.1",
|
| 869 |
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
| 870 |
+
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
| 871 |
+
"dev": true,
|
| 872 |
+
"license": "ISC"
|
| 873 |
+
},
|
| 874 |
+
"node_modules/postcss": {
|
| 875 |
+
"version": "8.5.6",
|
| 876 |
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
| 877 |
+
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
| 878 |
+
"dev": true,
|
| 879 |
+
"funding": [
|
| 880 |
+
{
|
| 881 |
+
"type": "opencollective",
|
| 882 |
+
"url": "https://opencollective.com/postcss/"
|
| 883 |
+
},
|
| 884 |
+
{
|
| 885 |
+
"type": "tidelift",
|
| 886 |
+
"url": "https://tidelift.com/funding/github/npm/postcss"
|
| 887 |
+
},
|
| 888 |
+
{
|
| 889 |
+
"type": "github",
|
| 890 |
+
"url": "https://github.com/sponsors/ai"
|
| 891 |
+
}
|
| 892 |
+
],
|
| 893 |
+
"license": "MIT",
|
| 894 |
+
"dependencies": {
|
| 895 |
+
"nanoid": "^3.3.11",
|
| 896 |
+
"picocolors": "^1.1.1",
|
| 897 |
+
"source-map-js": "^1.2.1"
|
| 898 |
+
},
|
| 899 |
+
"engines": {
|
| 900 |
+
"node": "^10 || ^12 || >=14"
|
| 901 |
+
}
|
| 902 |
+
},
|
| 903 |
+
"node_modules/react": {
|
| 904 |
+
"version": "18.3.1",
|
| 905 |
+
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
| 906 |
+
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
| 907 |
+
"license": "MIT",
|
| 908 |
+
"peer": true,
|
| 909 |
+
"dependencies": {
|
| 910 |
+
"loose-envify": "^1.1.0"
|
| 911 |
+
},
|
| 912 |
+
"engines": {
|
| 913 |
+
"node": ">=0.10.0"
|
| 914 |
+
}
|
| 915 |
+
},
|
| 916 |
+
"node_modules/react-dom": {
|
| 917 |
+
"version": "18.3.1",
|
| 918 |
+
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
| 919 |
+
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
| 920 |
+
"license": "MIT",
|
| 921 |
+
"dependencies": {
|
| 922 |
+
"loose-envify": "^1.1.0",
|
| 923 |
+
"scheduler": "^0.23.2"
|
| 924 |
+
},
|
| 925 |
+
"peerDependencies": {
|
| 926 |
+
"react": "^18.3.1"
|
| 927 |
+
}
|
| 928 |
+
},
|
| 929 |
+
"node_modules/rollup": {
|
| 930 |
+
"version": "4.55.1",
|
| 931 |
+
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
|
| 932 |
+
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
|
| 933 |
+
"dev": true,
|
| 934 |
+
"license": "MIT",
|
| 935 |
+
"dependencies": {
|
| 936 |
+
"@types/estree": "1.0.8"
|
| 937 |
+
},
|
| 938 |
+
"bin": {
|
| 939 |
+
"rollup": "dist/bin/rollup"
|
| 940 |
+
},
|
| 941 |
+
"engines": {
|
| 942 |
+
"node": ">=18.0.0",
|
| 943 |
+
"npm": ">=8.0.0"
|
| 944 |
+
},
|
| 945 |
+
"optionalDependencies": {
|
| 946 |
+
"@rollup/rollup-android-arm-eabi": "4.55.1",
|
| 947 |
+
"@rollup/rollup-android-arm64": "4.55.1",
|
| 948 |
+
"@rollup/rollup-darwin-arm64": "4.55.1",
|
| 949 |
+
"@rollup/rollup-darwin-x64": "4.55.1",
|
| 950 |
+
"@rollup/rollup-freebsd-arm64": "4.55.1",
|
| 951 |
+
"@rollup/rollup-freebsd-x64": "4.55.1",
|
| 952 |
+
"@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
|
| 953 |
+
"@rollup/rollup-linux-arm-musleabihf": "4.55.1",
|
| 954 |
+
"@rollup/rollup-linux-arm64-gnu": "4.55.1",
|
| 955 |
+
"@rollup/rollup-linux-arm64-musl": "4.55.1",
|
| 956 |
+
"@rollup/rollup-linux-loong64-gnu": "4.55.1",
|
| 957 |
+
"@rollup/rollup-linux-loong64-musl": "4.55.1",
|
| 958 |
+
"@rollup/rollup-linux-ppc64-gnu": "4.55.1",
|
| 959 |
+
"@rollup/rollup-linux-ppc64-musl": "4.55.1",
|
| 960 |
+
"@rollup/rollup-linux-riscv64-gnu": "4.55.1",
|
| 961 |
+
"@rollup/rollup-linux-riscv64-musl": "4.55.1",
|
| 962 |
+
"@rollup/rollup-linux-s390x-gnu": "4.55.1",
|
| 963 |
+
"@rollup/rollup-linux-x64-gnu": "4.55.1",
|
| 964 |
+
"@rollup/rollup-linux-x64-musl": "4.55.1",
|
| 965 |
+
"@rollup/rollup-openbsd-x64": "4.55.1",
|
| 966 |
+
"@rollup/rollup-openharmony-arm64": "4.55.1",
|
| 967 |
+
"@rollup/rollup-win32-arm64-msvc": "4.55.1",
|
| 968 |
+
"@rollup/rollup-win32-ia32-msvc": "4.55.1",
|
| 969 |
+
"@rollup/rollup-win32-x64-gnu": "4.55.1",
|
| 970 |
+
"@rollup/rollup-win32-x64-msvc": "4.55.1",
|
| 971 |
+
"fsevents": "~2.3.2"
|
| 972 |
+
}
|
| 973 |
+
},
|
| 974 |
+
"node_modules/scheduler": {
|
| 975 |
+
"version": "0.23.2",
|
| 976 |
+
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
| 977 |
+
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
| 978 |
+
"license": "MIT",
|
| 979 |
+
"dependencies": {
|
| 980 |
+
"loose-envify": "^1.1.0"
|
| 981 |
+
}
|
| 982 |
+
},
|
| 983 |
+
"node_modules/source-map-js": {
|
| 984 |
+
"version": "1.2.1",
|
| 985 |
+
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
| 986 |
+
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
| 987 |
+
"dev": true,
|
| 988 |
+
"license": "BSD-3-Clause",
|
| 989 |
+
"engines": {
|
| 990 |
+
"node": ">=0.10.0"
|
| 991 |
+
}
|
| 992 |
+
},
|
| 993 |
+
"node_modules/vite": {
|
| 994 |
+
"version": "5.4.21",
|
| 995 |
+
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
| 996 |
+
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
| 997 |
+
"dev": true,
|
| 998 |
+
"license": "MIT",
|
| 999 |
+
"dependencies": {
|
| 1000 |
+
"esbuild": "^0.21.3",
|
| 1001 |
+
"postcss": "^8.4.43",
|
| 1002 |
+
"rollup": "^4.20.0"
|
| 1003 |
+
},
|
| 1004 |
+
"bin": {
|
| 1005 |
+
"vite": "bin/vite.js"
|
| 1006 |
+
},
|
| 1007 |
+
"engines": {
|
| 1008 |
+
"node": "^18.0.0 || >=20.0.0"
|
| 1009 |
+
},
|
| 1010 |
+
"funding": {
|
| 1011 |
+
"url": "https://github.com/vitejs/vite?sponsor=1"
|
| 1012 |
+
},
|
| 1013 |
+
"optionalDependencies": {
|
| 1014 |
+
"fsevents": "~2.3.3"
|
| 1015 |
+
},
|
| 1016 |
+
"peerDependencies": {
|
| 1017 |
+
"@types/node": "^18.0.0 || >=20.0.0",
|
| 1018 |
+
"less": "*",
|
| 1019 |
+
"lightningcss": "^1.21.0",
|
| 1020 |
+
"sass": "*",
|
| 1021 |
+
"sass-embedded": "*",
|
| 1022 |
+
"stylus": "*",
|
| 1023 |
+
"sugarss": "*",
|
| 1024 |
+
"terser": "^5.4.0"
|
| 1025 |
+
},
|
| 1026 |
+
"peerDependenciesMeta": {
|
| 1027 |
+
"@types/node": {
|
| 1028 |
+
"optional": true
|
| 1029 |
+
},
|
| 1030 |
+
"less": {
|
| 1031 |
+
"optional": true
|
| 1032 |
+
},
|
| 1033 |
+
"lightningcss": {
|
| 1034 |
+
"optional": true
|
| 1035 |
+
},
|
| 1036 |
+
"sass": {
|
| 1037 |
+
"optional": true
|
| 1038 |
+
},
|
| 1039 |
+
"sass-embedded": {
|
| 1040 |
+
"optional": true
|
| 1041 |
+
},
|
| 1042 |
+
"stylus": {
|
| 1043 |
+
"optional": true
|
| 1044 |
+
},
|
| 1045 |
+
"sugarss": {
|
| 1046 |
+
"optional": true
|
| 1047 |
+
},
|
| 1048 |
+
"terser": {
|
| 1049 |
+
"optional": true
|
| 1050 |
+
}
|
| 1051 |
+
}
|
| 1052 |
+
}
|
| 1053 |
+
}
|
| 1054 |
+
}
|
web/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "paper-shelf-ui",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"react": "^18.2.0",
|
| 13 |
+
"react-dom": "^18.2.0",
|
| 14 |
+
"lucide-react": "^0.446.0"
|
| 15 |
+
},
|
| 16 |
+
"devDependencies": {
|
| 17 |
+
"vite": "^5.0.0"
|
| 18 |
+
}
|
| 19 |
+
}
|
web/src/App.jsx
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from "react";
|
| 2 |
+
import { Bookmark, Heart, Search, Layers, Smile, Sparkles, Star, Trophy, BarChart3, X, MessageCircle, MessageSquare, Info, Send } from "lucide-react";
|
| 3 |
+
import { recommend, addFavorite, getPersona, getHighlights } from "./api";
|
| 4 |
+
|
| 5 |
+
// --- Elegant Book Discovery UI ---
|
| 6 |
+
|
| 7 |
+
const CATEGORIES = ["All", "Fiction", "History", "Philosophy", "Science", "Art"];
|
| 8 |
+
const MOODS = ["All", "Happy", "Suspenseful", "Angry", "Sad", "Surprising"];
|
| 9 |
+
|
| 10 |
+
const StudyButton = ({ children, active, color, className, onClick }) => {
|
| 11 |
+
const colors = {
|
| 12 |
+
purple: "bg-[#b392ac] text-white hover:bg-[#9d7799]",
|
| 13 |
+
peach: "bg-[#f4acb7] text-white hover:bg-[#e89ba3]",
|
| 14 |
+
tab: "bg-transparent text-[#b392ac] border-b-2 border-[#b392ac]",
|
| 15 |
+
};
|
| 16 |
+
return (
|
| 17 |
+
<button
|
| 18 |
+
onClick={onClick}
|
| 19 |
+
className={`px-4 py-2 text-sm font-bold transition-all ${colors[color] || colors.purple} ${className || ""}`}
|
| 20 |
+
>
|
| 21 |
+
{children}
|
| 22 |
+
</button>
|
| 23 |
+
);
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
const StudyCard = ({ children, className }) => (
|
| 27 |
+
<div className={`bg-white border-2 border-[#333] shadow-md ${className || ""}`}>
|
| 28 |
+
{children}
|
| 29 |
+
</div>
|
| 30 |
+
);
|
| 31 |
+
|
| 32 |
+
const App = () => {
|
| 33 |
+
const [selectedBook, setSelectedBook] = useState(null);
|
| 34 |
+
const [messages, setMessages] = useState([]);
|
| 35 |
+
const [input, setInput] = useState("");
|
| 36 |
+
const [myCollection, setMyCollection] = useState([]);
|
| 37 |
+
const [showMyShelf, setShowMyShelf] = useState(false);
|
| 38 |
+
const [books, setBooks] = useState([]);
|
| 39 |
+
const [loading, setLoading] = useState(false);
|
| 40 |
+
const [error, setError] = useState("");
|
| 41 |
+
|
| 42 |
+
const [searchQuery, setSearchQuery] = useState("");
|
| 43 |
+
const [searchCategory, setSearchCategory] = useState("All");
|
| 44 |
+
const [searchMood, setSearchMood] = useState("All");
|
| 45 |
+
|
| 46 |
+
const handleSend = (text) => {
|
| 47 |
+
if (!text) return;
|
| 48 |
+
const newMsgs = [...messages, { role: 'user', content: text }];
|
| 49 |
+
setMessages(newMsgs);
|
| 50 |
+
setInput("");
|
| 51 |
+
setTimeout(() => {
|
| 52 |
+
setMessages([...newMsgs, { role: 'ai', content: `Based on "${selectedBook?.title || ''}" and your reading taste, I recommend paying attention to the thematic elements—they truly resonate with your preferences.` }]);
|
| 53 |
+
}, 600);
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
const toggleCollect = async (book) => {
|
| 57 |
+
try {
|
| 58 |
+
await addFavorite(book.isbn);
|
| 59 |
+
if (myCollection.some(b => b.isbn === book.isbn)) {
|
| 60 |
+
setMyCollection(myCollection.filter(b => b.isbn !== book.isbn));
|
| 61 |
+
} else {
|
| 62 |
+
setMyCollection([...myCollection, book]);
|
| 63 |
+
}
|
| 64 |
+
} catch (e) {
|
| 65 |
+
console.error(e);
|
| 66 |
+
}
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
const openBook = async (book) => {
|
| 70 |
+
setSelectedBook(book);
|
| 71 |
+
setMessages([]);
|
| 72 |
+
try {
|
| 73 |
+
const res = await getHighlights(book.isbn);
|
| 74 |
+
const meta = res?.meta || {};
|
| 75 |
+
setSelectedBook({ ...book, aiHighlight: (res?.highlights || []).join("\n") || '—', suggestedQuestions: [
|
| 76 |
+
`Who is the target audience for this book?`,
|
| 77 |
+
`Does the author have similar works?`,
|
| 78 |
+
`Can you summarize the main content?`
|
| 79 |
+
], desc: meta?.description || book.desc });
|
| 80 |
+
} catch (e) {
|
| 81 |
+
// keep default
|
| 82 |
+
}
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
const startDiscovery = async () => {
|
| 86 |
+
setLoading(true);
|
| 87 |
+
setError("");
|
| 88 |
+
try {
|
| 89 |
+
const recs = await recommend(searchQuery || 'adventure', searchCategory, searchMood);
|
| 90 |
+
const mapped = (recs || []).map((r, idx) => ({
|
| 91 |
+
id: r.isbn,
|
| 92 |
+
title: r.title,
|
| 93 |
+
author: r.authors,
|
| 94 |
+
category: searchCategory,
|
| 95 |
+
mood: searchMood,
|
| 96 |
+
rank: idx + 1,
|
| 97 |
+
rating: r.average_rating || 0,
|
| 98 |
+
tags: r.tags || [],
|
| 99 |
+
review_highlights: r.review_highlights || [],
|
| 100 |
+
desc: r.description,
|
| 101 |
+
img: r.thumbnail,
|
| 102 |
+
isbn: r.isbn,
|
| 103 |
+
emotions: r.emotions || {},
|
| 104 |
+
aiHighlight: '—',
|
| 105 |
+
suggestedQuestions: [
|
| 106 |
+
`Matches my current mood?`,
|
| 107 |
+
`Any similar recommendations?`,
|
| 108 |
+
`What's the core highlight?`
|
| 109 |
+
]
|
| 110 |
+
}));
|
| 111 |
+
setBooks(mapped);
|
| 112 |
+
} catch (e) {
|
| 113 |
+
setError(e.message || 'Failed to get recommendations');
|
| 114 |
+
} finally {
|
| 115 |
+
setLoading(false);
|
| 116 |
+
}
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
const getRecommendedBooks = () => {
|
| 120 |
+
if (myCollection.length === 0) return books.slice(0, 3);
|
| 121 |
+
return books.filter(b => !myCollection.some(cb => cb.isbn === b.isbn)).slice(0, 3);
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
const currentViewBooks = showMyShelf ? myCollection : books;
|
| 125 |
+
|
| 126 |
+
return (
|
| 127 |
+
<div className="min-h-screen bg-[#faf9f6] text-[#444] font-serif tracking-tight">
|
| 128 |
+
<header className="max-w-5xl mx-auto pt-10 px-4 flex justify-between items-end mb-12">
|
| 129 |
+
<div>
|
| 130 |
+
<div className="border border-[#333] px-4 py-1 bg-white shadow-[2px_2px_0px_0px_#eee] inline-block mb-2">
|
| 131 |
+
<h1 className="text-xl font-bold uppercase tracking-[0.2em] text-[#333]">Paper Shelf</h1>
|
| 132 |
+
</div>
|
| 133 |
+
<p className="text-[10px] text-gray-400 font-medium tracking-widest">Discover books that resonate with your soul</p>
|
| 134 |
+
</div>
|
| 135 |
+
<div className="flex gap-2">
|
| 136 |
+
<StudyButton
|
| 137 |
+
active={showMyShelf}
|
| 138 |
+
color={showMyShelf ? "purple" : "tab"}
|
| 139 |
+
onClick={() => setShowMyShelf(!showMyShelf)}
|
| 140 |
+
>
|
| 141 |
+
<Bookmark className="w-4 h-4 inline mr-1" /> {showMyShelf ? "Back to Gallery" : "My Collection"}
|
| 142 |
+
</StudyButton>
|
| 143 |
+
</div>
|
| 144 |
+
</header>
|
| 145 |
+
|
| 146 |
+
<main className="max-w-5xl mx-auto px-4 pb-20">
|
| 147 |
+
{!showMyShelf && (
|
| 148 |
+
<>
|
| 149 |
+
{myCollection.length > 0 && (
|
| 150 |
+
<div className="mb-12 animate-in fade-in slide-in-from-top-4 duration-700">
|
| 151 |
+
<h4 className="flex items-center gap-2 text-[10px] font-black uppercase text-[#b392ac] mb-4 tracking-widest">
|
| 152 |
+
<Sparkles className="w-3.5 h-3.5" /> Soul-Matched Recommendations
|
| 153 |
+
</h4>
|
| 154 |
+
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-hide">
|
| 155 |
+
{getRecommendedBooks().map(book => (
|
| 156 |
+
<div
|
| 157 |
+
key={book.id}
|
| 158 |
+
onClick={() => openBook(book)}
|
| 159 |
+
className="min-w-[280px] flex gap-4 bg-white border border-[#333] p-3 shadow-sm hover:shadow-md cursor-pointer transition-all"
|
| 160 |
+
>
|
| 161 |
+
<img src={book.img} className="w-20 h-28 object-cover border border-[#eee]" />
|
| 162 |
+
<div className="flex flex-col justify-between">
|
| 163 |
+
<div>
|
| 164 |
+
<h5 className="text-[12px] font-bold text-[#333]">{book.title}</h5>
|
| 165 |
+
<p className="text-[10px] text-gray-400 mt-1">Resonates with your "{book.mood}" preference</p>
|
| 166 |
+
</div>
|
| 167 |
+
<div className="flex gap-1">
|
| 168 |
+
{book.tags.slice(0, 2).map(t => <span key={t} className="text-[8px] px-1.5 py-0.5 bg-[#f8f9fa] border border-[#eee] text-[#999]">{t}</span>)}
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
))}
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
)}
|
| 176 |
+
|
| 177 |
+
<div className="max-w-4xl mx-auto mb-16 space-y-4">
|
| 178 |
+
<div className="grid grid-cols-1 md:grid-cols-12 gap-3 items-center">
|
| 179 |
+
<div className="md:col-span-6 flex items-center bg-white border border-[#ddd] p-2 shadow-sm">
|
| 180 |
+
<Search className="w-4 h-4 mr-3 text-gray-300 ml-2" />
|
| 181 |
+
<input
|
| 182 |
+
className="w-full outline-none text-sm placeholder-gray-400 bg-transparent font-serif"
|
| 183 |
+
placeholder="Search for a topic, mood, or dream..."
|
| 184 |
+
value={searchQuery}
|
| 185 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 186 |
+
/>
|
| 187 |
+
</div>
|
| 188 |
+
<div className="md:col-span-3 flex items-center bg-white border border-[#ddd] p-2 shadow-sm">
|
| 189 |
+
<Layers className="w-4 h-4 mr-3 text-gray-300 ml-2" />
|
| 190 |
+
<select
|
| 191 |
+
className="w-full outline-none text-sm bg-transparent text-gray-500 font-serif"
|
| 192 |
+
value={searchCategory}
|
| 193 |
+
onChange={(e) => setSearchCategory(e.target.value)}
|
| 194 |
+
>
|
| 195 |
+
{CATEGORIES.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
| 196 |
+
</select>
|
| 197 |
+
</div>
|
| 198 |
+
<div className="md:col-span-3 flex items-center bg-white border border-[#ddd] p-2 shadow-sm">
|
| 199 |
+
<Smile className="w-4 h-4 mr-3 text-gray-300 ml-2" />
|
| 200 |
+
<select
|
| 201 |
+
className="w-full outline-none text-sm bg-transparent text-gray-500 font-serif"
|
| 202 |
+
value={searchMood}
|
| 203 |
+
onChange={(e) => setSearchMood(e.target.value)}
|
| 204 |
+
>
|
| 205 |
+
{MOODS.map(mood => <option key={mood} value={mood}>{mood}</option>)}
|
| 206 |
+
</select>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
<div className="flex justify-center">
|
| 210 |
+
<StudyButton active color="purple" className="px-12 py-2" onClick={startDiscovery}>
|
| 211 |
+
Start Discovery
|
| 212 |
+
</StudyButton>
|
| 213 |
+
</div>
|
| 214 |
+
{loading && <div className="text-center text-xs text-gray-400">Loading...</div>}
|
| 215 |
+
{error && <div className="text-center text-xs text-red-400">{error}</div>}
|
| 216 |
+
</div>
|
| 217 |
+
</>
|
| 218 |
+
)}
|
| 219 |
+
|
| 220 |
+
{showMyShelf && (
|
| 221 |
+
<div className="mb-8 flex items-center gap-4 text-xs font-bold text-[#b392ac] bg-[#e5d9f2]/30 p-4 border border-[#b392ac]/20">
|
| 222 |
+
<BarChart3 className="w-4 h-4" />
|
| 223 |
+
Your collection shows a preference for: {myCollection.map(b => b.mood).filter((v, i, a) => a.indexOf(v) === i).join(", ")}
|
| 224 |
+
</div>
|
| 225 |
+
)}
|
| 226 |
+
|
| 227 |
+
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
| 228 |
+
{currentViewBooks.length > 0 ? currentViewBooks.map((book, idx) => (
|
| 229 |
+
<div
|
| 230 |
+
key={idx}
|
| 231 |
+
onClick={() => openBook(book)}
|
| 232 |
+
className="group cursor-pointer transform hover:-translate-y-1 transition-all"
|
| 233 |
+
>
|
| 234 |
+
<div className="bg-white border border-[#eee] p-1 relative shadow-sm group-hover:shadow-md overflow-hidden">
|
| 235 |
+
<img src={book.img} alt={book.title} className="w-full aspect-[3/4] object-cover opacity-90 group-hover:opacity-100 transition-opacity" />
|
| 236 |
+
<div className="absolute inset-0 bg-white/80 flex items-center justify-center p-4 opacity-0 group-hover:opacity-100 transition-opacity text-center px-4">
|
| 237 |
+
<p className="text-[10px] font-bold text-[#b392ac] leading-relaxed italic">
|
| 238 |
+
{book.aiHighlight}
|
| 239 |
+
</p>
|
| 240 |
+
</div>
|
| 241 |
+
{myCollection.some(b => b.isbn === book.isbn) && (
|
| 242 |
+
<div className="absolute top-1 right-1 bg-[#f4acb7] p-1 shadow-sm">
|
| 243 |
+
<Heart className="w-3 h-3 text-white fill-current" />
|
| 244 |
+
</div>
|
| 245 |
+
)}
|
| 246 |
+
</div>
|
| 247 |
+
<h3 className="mt-3 text-[12px] font-bold text-[#555] truncate">{book.title}</h3>
|
| 248 |
+
<div className="flex justify-between items-center mt-1">
|
| 249 |
+
<span className="text-[9px] text-gray-400 tracking-tighter">{book.author}</span>
|
| 250 |
+
{book.emotions && Object.keys(book.emotions).length > 0 ? (
|
| 251 |
+
<span className="text-[9px] bg-[#f8f9fa] border border-[#eee] px-1 text-[#999] capitalize">
|
| 252 |
+
{Object.entries(book.emotions).reduce((a, b) => a[1] > b[1] ? a : b)[0]}
|
| 253 |
+
</span>
|
| 254 |
+
) : (
|
| 255 |
+
<span className="text-[9px] bg-[#f8f9fa] border border-[#eee] px-1 text-[#999]">—</span>
|
| 256 |
+
)}
|
| 257 |
+
</div>
|
| 258 |
+
</div>
|
| 259 |
+
)) : (
|
| 260 |
+
<div className="col-span-full py-20 text-center text-gray-400 text-xs italic">
|
| 261 |
+
No books here yet. Start discovering to build your collection.
|
| 262 |
+
</div>
|
| 263 |
+
)}
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
+
{selectedBook && (
|
| 267 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/5 backdrop-blur-sm animate-in fade-in duration-300 overflow-y-auto">
|
| 268 |
+
<StudyCard className="relative bg-white max-w-5xl w-full shadow-2xl border-[#333] my-8">
|
| 269 |
+
<button
|
| 270 |
+
onClick={() => setSelectedBook(null)}
|
| 271 |
+
className="absolute top-4 right-4 text-gray-300 hover:text-gray-600 transition-colors z-10"
|
| 272 |
+
>
|
| 273 |
+
<X className="w-6 h-6" />
|
| 274 |
+
</button>
|
| 275 |
+
|
| 276 |
+
<div className="grid md:grid-cols-12 gap-8 md:gap-10 px-6 md:px-10 py-6">
|
| 277 |
+
<div className="md:col-span-5 flex flex-col items-center border-r border-[#f5f5f5] pr-0 md:pr-6">
|
| 278 |
+
<div className="border border-[#eee] p-1 bg-white shadow-sm mb-2 w-52 md:w-56">
|
| 279 |
+
<img src={selectedBook.img} alt="cover" className="w-full aspect-[3/4] object-cover" />
|
| 280 |
+
</div>
|
| 281 |
+
|
| 282 |
+
<p className="text-xs text-[#999] mb-2 tracking-tighter text-center w-full">{selectedBook.author}</p>
|
| 283 |
+
|
| 284 |
+
<h2 className="text-xl font-bold text-[#333] mb-1 text-center md:text-left w-full">{selectedBook.title}</h2>
|
| 285 |
+
<p className="text-xs text-[#999] mb-2 tracking-tighter text-center md:text-left w-full">ISBN: {selectedBook.isbn}</p>
|
| 286 |
+
|
| 287 |
+
<div className="bg-[#fff9f9] border border-[#f4acb7] p-4 w-full relative mb-4">
|
| 288 |
+
<Sparkles className="w-3 h-3 text-[#f4acb7] absolute -top-1.5 -left-1.5 fill-current" />
|
| 289 |
+
<div className="flex items-center justify-between mb-2">
|
| 290 |
+
<span className="text-[11px] font-bold text-[#f4acb7]">{selectedBook.rating ? selectedBook.rating.toFixed(1) : '0.0'}</span>
|
| 291 |
+
<div className="flex gap-0.5 text-[#f4acb7]">
|
| 292 |
+
{[1,2,3,4,5].map(i => <Star key={i} className={`w-3 h-3 ${i <= selectedBook.rating ? 'fill-current' : ''}`} />)}
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
<p className="text-[11px] font-bold text-[#f4acb7] italic leading-relaxed">
|
| 296 |
+
{selectedBook.aiHighlight}
|
| 297 |
+
</p>
|
| 298 |
+
</div>
|
| 299 |
+
|
| 300 |
+
{selectedBook.review_highlights && selectedBook.review_highlights.length > 0 && (
|
| 301 |
+
<div className="w-full space-y-2 text-left">
|
| 302 |
+
{selectedBook.review_highlights.slice(0, 3).map((highlight, idx) => {
|
| 303 |
+
const isCompleteSentence = /^[A-Z]/.test(highlight.trim());
|
| 304 |
+
const prefix = isCompleteSentence ? '' : '...';
|
| 305 |
+
return (
|
| 306 |
+
<p key={idx} className="text-[10px] text-[#666] leading-relaxed italic pl-2">
|
| 307 |
+
- "{prefix}{highlight}"
|
| 308 |
+
</p>
|
| 309 |
+
);
|
| 310 |
+
})}
|
| 311 |
+
</div>
|
| 312 |
+
)}
|
| 313 |
+
</div>
|
| 314 |
+
|
| 315 |
+
<div className="md:col-span-7 flex flex-col space-y-6">
|
| 316 |
+
<div className="space-y-2">
|
| 317 |
+
<h4 className="flex items-center gap-2 text-[10px] font-bold uppercase text-gray-400 tracking-wider">
|
| 318 |
+
<Info className="w-3.5 h-3.5" /> Summary
|
| 319 |
+
</h4>
|
| 320 |
+
<div className="p-4 bg-white border border-[#eee] text-[12px] leading-relaxed text-[#666] italic border-l-[4px] border-l-[#b392ac]">
|
| 321 |
+
"{selectedBook.desc}"
|
| 322 |
+
</div>
|
| 323 |
+
</div>
|
| 324 |
+
|
| 325 |
+
<div className="flex-grow flex flex-col border border-[#eee] bg-[#faf9f6] overflow-hidden h-[300px]">
|
| 326 |
+
<div className="p-2 border-b border-[#eee] bg-white flex justify-between items-center">
|
| 327 |
+
<span className="text-[10px] font-bold text-[#b392ac] flex items-center gap-2 uppercase tracking-widest">
|
| 328 |
+
<MessageSquare className="w-3 h-3" /> Discussion
|
| 329 |
+
</span>
|
| 330 |
+
</div>
|
| 331 |
+
<div className="flex-grow overflow-y-auto p-4 space-y-3">
|
| 332 |
+
<div className="flex justify-start">
|
| 333 |
+
<div className="max-w-[85%] p-2 bg-white border border-[#eee] text-[11px] text-[#735d78] shadow-sm">
|
| 334 |
+
Hello! Based on your collection preferences, I found this book's {selectedBook.mood} atmosphere pairs beautifully with your taste. Would you like to explore its themes?
|
| 335 |
+
</div>
|
| 336 |
+
</div>
|
| 337 |
+
{messages.map((m, i) => (
|
| 338 |
+
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
| 339 |
+
<div className={`max-w-[80%] p-2 border text-[11px] shadow-sm ${
|
| 340 |
+
m.role === 'user'
|
| 341 |
+
? 'bg-[#b392ac] text-white border-[#b392ac]'
|
| 342 |
+
: 'bg-white text-[#666] border-[#eee]'
|
| 343 |
+
}`}>
|
| 344 |
+
{m.content}
|
| 345 |
+
</div>
|
| 346 |
+
</div>
|
| 347 |
+
))}
|
| 348 |
+
</div>
|
| 349 |
+
<div className="p-3 bg-white border-t border-[#eee] space-y-3">
|
| 350 |
+
<div className="flex flex-wrap gap-2">
|
| 351 |
+
{(selectedBook.suggestedQuestions || []).map((q, idx) => (
|
| 352 |
+
<button
|
| 353 |
+
key={idx}
|
| 354 |
+
onClick={() => handleSend(q)}
|
| 355 |
+
className="text-[9px] px-2 py-1 bg-[#f8f9fa] border border-[#eee] text-gray-500 hover:border-[#b392ac] hover:text-[#b392ac] transition-colors"
|
| 356 |
+
>
|
| 357 |
+
{q}
|
| 358 |
+
</button>
|
| 359 |
+
))}
|
| 360 |
+
</div>
|
| 361 |
+
<div className="flex gap-2">
|
| 362 |
+
<input
|
| 363 |
+
value={input}
|
| 364 |
+
onChange={(e) => setInput(e.target.value)}
|
| 365 |
+
onKeyDown={(e) => e.key === 'Enter' && handleSend(input)}
|
| 366 |
+
className="flex-grow border border-[#eee] p-2 text-[11px] outline-none focus:border-[#b392ac] bg-[#faf9f6] font-serif"
|
| 367 |
+
placeholder="Ask a question..."
|
| 368 |
+
/>
|
| 369 |
+
<button onClick={() => handleSend(input)} className="bg-[#333] text-white p-2">
|
| 370 |
+
<Send className="w-3.5 h-3.5" />
|
| 371 |
+
</button>
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
</div>
|
| 375 |
+
|
| 376 |
+
<div className="flex gap-3">
|
| 377 |
+
<StudyButton
|
| 378 |
+
active
|
| 379 |
+
color={myCollection.some(b => b.isbn === selectedBook.isbn) ? "peach" : "purple"}
|
| 380 |
+
className="flex-grow py-3 text-sm flex items-center justify-center gap-2 font-bold"
|
| 381 |
+
onClick={() => toggleCollect(selectedBook)}
|
| 382 |
+
>
|
| 383 |
+
<Bookmark className={`w-4 h-4 ${myCollection.some(b => b.isbn === selectedBook.isbn) ? 'fill-current' : ''}`} />
|
| 384 |
+
{myCollection.some(b => b.isbn === selectedBook.isbn) ? "In Collection" : "Add to Collection"}
|
| 385 |
+
</StudyButton>
|
| 386 |
+
</div>
|
| 387 |
+
</div>
|
| 388 |
+
</div>
|
| 389 |
+
</StudyCard>
|
| 390 |
+
</div>
|
| 391 |
+
)}
|
| 392 |
+
</main>
|
| 393 |
+
|
| 394 |
+
<footer className="mt-16 text-center text-[9px] font-medium text-gray-300 uppercase tracking-widest pb-10 border-t border-[#eee] pt-10">
|
| 395 |
+
Paper Shelf // 2026 Your Personal Library
|
| 396 |
+
</footer>
|
| 397 |
+
</div>
|
| 398 |
+
);
|
| 399 |
+
};
|
| 400 |
+
|
| 401 |
+
export default App;
|
web/src/api.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:6006";
|
| 2 |
+
|
| 3 |
+
export async function recommend(query, category = "All", tone = "All") {
|
| 4 |
+
const body = { query, category, tone };
|
| 5 |
+
const resp = await fetch(`${API_URL}/recommend`, {
|
| 6 |
+
method: "POST",
|
| 7 |
+
headers: { "Content-Type": "application/json" },
|
| 8 |
+
body: JSON.stringify(body),
|
| 9 |
+
});
|
| 10 |
+
if (!resp.ok) throw new Error(await resp.text());
|
| 11 |
+
const data = await resp.json();
|
| 12 |
+
return data.recommendations || [];
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export async function addFavorite(isbn, userId = "local") {
|
| 16 |
+
const resp = await fetch(`${API_URL}/favorites/add`, {
|
| 17 |
+
method: "POST",
|
| 18 |
+
headers: { "Content-Type": "application/json" },
|
| 19 |
+
body: JSON.stringify({ isbn, user_id: userId }),
|
| 20 |
+
});
|
| 21 |
+
if (!resp.ok) throw new Error(await resp.text());
|
| 22 |
+
return resp.json();
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export async function getPersona(userId = "local") {
|
| 26 |
+
const resp = await fetch(`${API_URL}/user/${userId}/persona`);
|
| 27 |
+
if (!resp.ok) throw new Error(await resp.text());
|
| 28 |
+
return resp.json();
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export async function getHighlights(isbn, userId = "local") {
|
| 32 |
+
const resp = await fetch(`${API_URL}/marketing/highlights`, {
|
| 33 |
+
method: "POST",
|
| 34 |
+
headers: { "Content-Type": "application/json" },
|
| 35 |
+
body: JSON.stringify({ isbn, user_id: userId }),
|
| 36 |
+
});
|
| 37 |
+
if (!resp.ok) throw new Error(await resp.text());
|
| 38 |
+
return resp.json();
|
| 39 |
+
}
|
web/src/main.jsx
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { createRoot } from "react-dom/client";
|
| 3 |
+
import App from "./App.jsx";
|
| 4 |
+
|
| 5 |
+
createRoot(document.getElementById("root")).render(
|
| 6 |
+
<React.StrictMode>
|
| 7 |
+
<App />
|
| 8 |
+
</React.StrictMode>
|
| 9 |
+
);
|