Nagharjun Mathi Mariappan commited on
Commit ·
8ad64dc
0
Parent(s):
Initial commit for Space deployment
Browse files- .gitattributes +38 -0
- .gitignore +207 -0
- Dockerfile +32 -0
- LICENSE +21 -0
- README.md +5 -0
- backend/engines/cards_engine.py +108 -0
- backend/engines/movies_engine.py +115 -0
- backend/main.py +128 -0
- backend/models/cards/card_item_features.npy +3 -0
- backend/models/cards/cards_meta.csv +0 -0
- backend/models/cards/cards_two_tower.pt +3 -0
- backend/models/cards/num_scaler.pkl +3 -0
- backend/models/movies/idx2movie_id.pkl +3 -0
- backend/models/movies/idx2user_id.pkl +3 -0
- backend/models/movies/movie_id2idx.pkl +3 -0
- backend/models/movies/movie_ranker.pt +3 -0
- backend/models/movies/movies_meta.csv +0 -0
- backend/models/movies/user_emb_train.npy +3 -0
- backend/models/movies/user_id2idx.pkl +3 -0
- backend/requirements.txt +9 -0
- backend/train_cards.py +393 -0
- backend/train_movies.py +166 -0
- data/movies.csv +3 -0
- data/tccp_cards.csv +3 -0
- frontend/.gitignore +24 -0
- frontend/README.md +16 -0
- frontend/eslint.config.js +29 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +27 -0
- frontend/public/vite.svg +1 -0
- frontend/src/App.css +42 -0
- frontend/src/App.jsx +15 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/components/CardForm.jsx +109 -0
- frontend/src/components/MovieForm.jsx +75 -0
- frontend/src/components/NavBar.jsx +19 -0
- frontend/src/components/ResultsGrid.jsx +16 -0
- frontend/src/index.css +68 -0
- frontend/src/main.jsx +10 -0
- frontend/src/pages/CardsPage.jsx +43 -0
- frontend/src/pages/MoviesPage.jsx +70 -0
- frontend/src/styles.css +85 -0
- frontend/vite.config.js +7 -0
.gitattributes
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
backend/models/movies/*.npy filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
backend/models/**/*.pt filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
data/*.csv filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[codz]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Distribution / packaging
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
parts/
|
| 20 |
+
sdist/
|
| 21 |
+
var/
|
| 22 |
+
wheels/
|
| 23 |
+
share/python-wheels/
|
| 24 |
+
*.egg-info/
|
| 25 |
+
.installed.cfg
|
| 26 |
+
*.egg
|
| 27 |
+
MANIFEST
|
| 28 |
+
|
| 29 |
+
# PyInstaller
|
| 30 |
+
# Usually these files are written by a python script from a template
|
| 31 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 32 |
+
*.manifest
|
| 33 |
+
*.spec
|
| 34 |
+
|
| 35 |
+
# Installer logs
|
| 36 |
+
pip-log.txt
|
| 37 |
+
pip-delete-this-directory.txt
|
| 38 |
+
|
| 39 |
+
# Unit test / coverage reports
|
| 40 |
+
htmlcov/
|
| 41 |
+
.tox/
|
| 42 |
+
.nox/
|
| 43 |
+
.coverage
|
| 44 |
+
.coverage.*
|
| 45 |
+
.cache
|
| 46 |
+
nosetests.xml
|
| 47 |
+
coverage.xml
|
| 48 |
+
*.cover
|
| 49 |
+
*.py.cover
|
| 50 |
+
.hypothesis/
|
| 51 |
+
.pytest_cache/
|
| 52 |
+
cover/
|
| 53 |
+
|
| 54 |
+
# Translations
|
| 55 |
+
*.mo
|
| 56 |
+
*.pot
|
| 57 |
+
|
| 58 |
+
# Django stuff:
|
| 59 |
+
*.log
|
| 60 |
+
local_settings.py
|
| 61 |
+
db.sqlite3
|
| 62 |
+
db.sqlite3-journal
|
| 63 |
+
|
| 64 |
+
# Flask stuff:
|
| 65 |
+
instance/
|
| 66 |
+
.webassets-cache
|
| 67 |
+
|
| 68 |
+
# Scrapy stuff:
|
| 69 |
+
.scrapy
|
| 70 |
+
|
| 71 |
+
# Sphinx documentation
|
| 72 |
+
docs/_build/
|
| 73 |
+
|
| 74 |
+
# PyBuilder
|
| 75 |
+
.pybuilder/
|
| 76 |
+
target/
|
| 77 |
+
|
| 78 |
+
# Jupyter Notebook
|
| 79 |
+
.ipynb_checkpoints
|
| 80 |
+
|
| 81 |
+
# IPython
|
| 82 |
+
profile_default/
|
| 83 |
+
ipython_config.py
|
| 84 |
+
|
| 85 |
+
# pyenv
|
| 86 |
+
# For a library or package, you might want to ignore these files since the code is
|
| 87 |
+
# intended to run in multiple environments; otherwise, check them in:
|
| 88 |
+
# .python-version
|
| 89 |
+
|
| 90 |
+
# pipenv
|
| 91 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 92 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 93 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 94 |
+
# install all needed dependencies.
|
| 95 |
+
#Pipfile.lock
|
| 96 |
+
|
| 97 |
+
# UV
|
| 98 |
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
| 99 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 100 |
+
# commonly ignored for libraries.
|
| 101 |
+
#uv.lock
|
| 102 |
+
|
| 103 |
+
# poetry
|
| 104 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
| 105 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 106 |
+
# commonly ignored for libraries.
|
| 107 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
| 108 |
+
#poetry.lock
|
| 109 |
+
#poetry.toml
|
| 110 |
+
|
| 111 |
+
# pdm
|
| 112 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 113 |
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
| 114 |
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
| 115 |
+
#pdm.lock
|
| 116 |
+
#pdm.toml
|
| 117 |
+
.pdm-python
|
| 118 |
+
.pdm-build/
|
| 119 |
+
|
| 120 |
+
# pixi
|
| 121 |
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
| 122 |
+
#pixi.lock
|
| 123 |
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
| 124 |
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
| 125 |
+
.pixi
|
| 126 |
+
|
| 127 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
| 128 |
+
__pypackages__/
|
| 129 |
+
|
| 130 |
+
# Celery stuff
|
| 131 |
+
celerybeat-schedule
|
| 132 |
+
celerybeat.pid
|
| 133 |
+
|
| 134 |
+
# SageMath parsed files
|
| 135 |
+
*.sage.py
|
| 136 |
+
|
| 137 |
+
# Environments
|
| 138 |
+
.env
|
| 139 |
+
.envrc
|
| 140 |
+
.venv
|
| 141 |
+
env/
|
| 142 |
+
venv/
|
| 143 |
+
ENV/
|
| 144 |
+
env.bak/
|
| 145 |
+
venv.bak/
|
| 146 |
+
|
| 147 |
+
# Spyder project settings
|
| 148 |
+
.spyderproject
|
| 149 |
+
.spyproject
|
| 150 |
+
|
| 151 |
+
# Rope project settings
|
| 152 |
+
.ropeproject
|
| 153 |
+
|
| 154 |
+
# mkdocs documentation
|
| 155 |
+
/site
|
| 156 |
+
|
| 157 |
+
# mypy
|
| 158 |
+
.mypy_cache/
|
| 159 |
+
.dmypy.json
|
| 160 |
+
dmypy.json
|
| 161 |
+
|
| 162 |
+
# Pyre type checker
|
| 163 |
+
.pyre/
|
| 164 |
+
|
| 165 |
+
# pytype static type analyzer
|
| 166 |
+
.pytype/
|
| 167 |
+
|
| 168 |
+
# Cython debug symbols
|
| 169 |
+
cython_debug/
|
| 170 |
+
|
| 171 |
+
# PyCharm
|
| 172 |
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
| 173 |
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
| 174 |
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 175 |
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 176 |
+
#.idea/
|
| 177 |
+
|
| 178 |
+
# Abstra
|
| 179 |
+
# Abstra is an AI-powered process automation framework.
|
| 180 |
+
# Ignore directories containing user credentials, local state, and settings.
|
| 181 |
+
# Learn more at https://abstra.io/docs
|
| 182 |
+
.abstra/
|
| 183 |
+
|
| 184 |
+
# Visual Studio Code
|
| 185 |
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
| 186 |
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
| 187 |
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
| 188 |
+
# you could uncomment the following to ignore the entire vscode folder
|
| 189 |
+
# .vscode/
|
| 190 |
+
|
| 191 |
+
# Ruff stuff:
|
| 192 |
+
.ruff_cache/
|
| 193 |
+
|
| 194 |
+
# PyPI configuration file
|
| 195 |
+
.pypirc
|
| 196 |
+
|
| 197 |
+
# Cursor
|
| 198 |
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
| 199 |
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
| 200 |
+
# refer to https://docs.cursor.com/context/ignore-files
|
| 201 |
+
.cursorignore
|
| 202 |
+
.cursorindexingignore
|
| 203 |
+
|
| 204 |
+
# Marimo
|
| 205 |
+
marimo/_static/
|
| 206 |
+
marimo/_lsp/
|
| 207 |
+
__marimo__/
|
Dockerfile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#Frontend
|
| 2 |
+
FROM node:20-alpine AS fe
|
| 3 |
+
WORKDIR /fe
|
| 4 |
+
|
| 5 |
+
COPY frontend/package*.json ./
|
| 6 |
+
RUN npm ci
|
| 7 |
+
|
| 8 |
+
COPY frontend/ ./
|
| 9 |
+
RUN npm run build
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
#Backend
|
| 13 |
+
FROM python:3.11-slim
|
| 14 |
+
WORKDIR /app
|
| 15 |
+
|
| 16 |
+
ENV HF_HOME=/tmp/hf \
|
| 17 |
+
TRANSFORMERS_CACHE=/tmp/hf/transformers \
|
| 18 |
+
SENTENCE_TRANSFORMERS_HOME=/tmp/hf/sentence_transformers \
|
| 19 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 20 |
+
PYTHONUNBUFFERED=1
|
| 21 |
+
|
| 22 |
+
COPY backend/ /app/backend/
|
| 23 |
+
|
| 24 |
+
COPY backend/requirements.txt /app/requirements.txt
|
| 25 |
+
RUN pip install --no-cache-dir -r /app/requirements.txt
|
| 26 |
+
|
| 27 |
+
RUN mkdir -p /app/backend/static
|
| 28 |
+
COPY --from=fe /fe/dist /app/backend/static
|
| 29 |
+
|
| 30 |
+
EXPOSE 7860
|
| 31 |
+
|
| 32 |
+
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 Nagharjun Mathi Mariappan
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Recommendation Engine
|
| 3 |
+
sdk: docker
|
| 4 |
+
app_port: 7860
|
| 5 |
+
---
|
backend/engines/cards_engine.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import numpy as np
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn as nn
|
| 6 |
+
|
| 7 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TwoTower(nn.Module):
|
| 11 |
+
def __init__(self, user_dim, item_dim, hidden=128):
|
| 12 |
+
super().__init__()
|
| 13 |
+
self.user = nn.Sequential(
|
| 14 |
+
nn.Linear(user_dim, hidden),
|
| 15 |
+
nn.ReLU(),
|
| 16 |
+
nn.Linear(hidden, hidden),
|
| 17 |
+
nn.ReLU(),
|
| 18 |
+
)
|
| 19 |
+
self.item = nn.Sequential(
|
| 20 |
+
nn.Linear(item_dim, hidden),
|
| 21 |
+
nn.ReLU(),
|
| 22 |
+
nn.Linear(hidden, hidden),
|
| 23 |
+
nn.ReLU(),
|
| 24 |
+
)
|
| 25 |
+
self.head = nn.Sequential(
|
| 26 |
+
nn.Linear(hidden * 2, hidden),
|
| 27 |
+
nn.ReLU(),
|
| 28 |
+
nn.Linear(hidden, 1),
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
def forward(self, u, x):
|
| 32 |
+
ue = self.user(u)
|
| 33 |
+
ie = self.item(x)
|
| 34 |
+
z = torch.cat([ue, ie], dim=-1)
|
| 35 |
+
return self.head(z).squeeze(-1)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _user_vector(req):
|
| 39 |
+
pref_map = {"cashback": 0, "travel": 1, "points": 2, "none": 3}
|
| 40 |
+
onehot = np.zeros(4, dtype=np.float32)
|
| 41 |
+
onehot[pref_map.get(req.get("rewards_pref", "none"), 3)] = 1.0
|
| 42 |
+
|
| 43 |
+
income_log = np.log1p(float(req["annual_income"])) / 12.0
|
| 44 |
+
score_norm = (float(req["credit_score"]) - 300.0) / 550.0
|
| 45 |
+
|
| 46 |
+
spend = np.array(
|
| 47 |
+
[
|
| 48 |
+
float(req["spend_groceries"]),
|
| 49 |
+
float(req["spend_dining"]),
|
| 50 |
+
float(req["spend_gas"]),
|
| 51 |
+
float(req["spend_travel"]),
|
| 52 |
+
float(req["spend_online"]),
|
| 53 |
+
],
|
| 54 |
+
dtype=np.float32,
|
| 55 |
+
)
|
| 56 |
+
spend = spend / (spend.sum() + 1e-6)
|
| 57 |
+
|
| 58 |
+
x = np.concatenate(
|
| 59 |
+
[
|
| 60 |
+
np.array(
|
| 61 |
+
[
|
| 62 |
+
score_norm,
|
| 63 |
+
income_log,
|
| 64 |
+
1.0 if req["carry_balance"] else 0.0,
|
| 65 |
+
1.0 if req["travel_abroad"] else 0.0,
|
| 66 |
+
1.0 if req["no_annual_fee"] else 0.0,
|
| 67 |
+
1.0 if req["balance_transfer"] else 0.0,
|
| 68 |
+
],
|
| 69 |
+
dtype=np.float32,
|
| 70 |
+
),
|
| 71 |
+
onehot,
|
| 72 |
+
spend,
|
| 73 |
+
]
|
| 74 |
+
)
|
| 75 |
+
return x.astype(np.float32)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class CardsEngine:
|
| 79 |
+
def __init__(self, model_dir):
|
| 80 |
+
self.model_dir = model_dir
|
| 81 |
+
|
| 82 |
+
meta_path = os.path.join(self.model_dir, "cards_meta.csv")
|
| 83 |
+
feats_path = os.path.join(self.model_dir, "card_item_features.npy")
|
| 84 |
+
weights_path = os.path.join(self.model_dir, "cards_two_tower.pt")
|
| 85 |
+
|
| 86 |
+
self.meta = pd.read_csv(meta_path)
|
| 87 |
+
self.item_features = np.load(feats_path).astype(np.float32)
|
| 88 |
+
|
| 89 |
+
user_dim = 15
|
| 90 |
+
item_dim = self.item_features.shape[1]
|
| 91 |
+
|
| 92 |
+
self.model = TwoTower(user_dim=user_dim, item_dim=item_dim, hidden=128).to(DEVICE)
|
| 93 |
+
self.model.load_state_dict(torch.load(weights_path, map_location=DEVICE))
|
| 94 |
+
self.model.eval()
|
| 95 |
+
|
| 96 |
+
def recommend(self, user_req, top_k=10):
|
| 97 |
+
uvec = _user_vector(user_req)
|
| 98 |
+
U = np.repeat(uvec[None, :], self.item_features.shape[0], axis=0)
|
| 99 |
+
|
| 100 |
+
with torch.no_grad():
|
| 101 |
+
u = torch.from_numpy(U).to(DEVICE)
|
| 102 |
+
x = torch.from_numpy(self.item_features).to(DEVICE)
|
| 103 |
+
score = torch.sigmoid(self.model(u, x)).cpu().numpy()
|
| 104 |
+
|
| 105 |
+
idx = np.argsort(score)[::-1][:top_k]
|
| 106 |
+
out = self.meta.iloc[idx].copy()
|
| 107 |
+
out["score"] = score[idx]
|
| 108 |
+
return out
|
backend/engines/movies_engine.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import numpy as np
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import joblib
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn as nn
|
| 7 |
+
from sentence_transformers import SentenceTransformer
|
| 8 |
+
|
| 9 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class MovieRanker(nn.Module):
|
| 13 |
+
def __init__(self, dim=384, hidden=256):
|
| 14 |
+
super().__init__()
|
| 15 |
+
self.net = nn.Sequential(
|
| 16 |
+
nn.Linear(dim * 2, hidden),
|
| 17 |
+
nn.ReLU(),
|
| 18 |
+
nn.Linear(hidden, hidden),
|
| 19 |
+
nn.ReLU(),
|
| 20 |
+
nn.Linear(hidden, 1),
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
def forward(self, u, m):
|
| 24 |
+
x = torch.cat([u, m], dim=-1)
|
| 25 |
+
return self.net(x).squeeze(-1)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class MoviesEngine:
|
| 29 |
+
def __init__(self, model_dir):
|
| 30 |
+
self.model_dir = model_dir
|
| 31 |
+
|
| 32 |
+
movies_path = os.path.join(model_dir, "movies_meta.csv")
|
| 33 |
+
idmap_path = os.path.join(model_dir, "movie_id2idx.pkl")
|
| 34 |
+
emb_path = os.path.join(model_dir, "movie_emb.npy")
|
| 35 |
+
model_path = os.path.join(model_dir, "movie_ranker.pt")
|
| 36 |
+
|
| 37 |
+
self.movies = pd.read_csv(movies_path)
|
| 38 |
+
self.movie_id2idx = joblib.load(idmap_path)
|
| 39 |
+
self.movie_emb = np.load(emb_path).astype(np.float32)
|
| 40 |
+
|
| 41 |
+
self.embedder = SentenceTransformer("all-MiniLM-L6-v2")
|
| 42 |
+
|
| 43 |
+
self.model = MovieRanker(dim=self.movie_emb.shape[1], hidden=256).to(DEVICE)
|
| 44 |
+
self.model.load_state_dict(torch.load(model_path, map_location=DEVICE))
|
| 45 |
+
self.model.eval()
|
| 46 |
+
|
| 47 |
+
self._title_lower = (
|
| 48 |
+
self.movies["title"]
|
| 49 |
+
.fillna("")
|
| 50 |
+
.astype(str)
|
| 51 |
+
.str.lower()
|
| 52 |
+
.values
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
def search(self, q: str, limit=10, min_sim=0.40):
|
| 56 |
+
q = (q or "").strip()
|
| 57 |
+
if not q:
|
| 58 |
+
return []
|
| 59 |
+
|
| 60 |
+
q_emb = self.embedder.encode([q], show_progress_bar=False)
|
| 61 |
+
q_emb = np.asarray(q_emb[0], dtype=np.float32)
|
| 62 |
+
|
| 63 |
+
eps = 1e-8
|
| 64 |
+
q_norm = np.linalg.norm(q_emb) + eps
|
| 65 |
+
m_norm = np.linalg.norm(self.movie_emb, axis=1) + eps
|
| 66 |
+
|
| 67 |
+
sims = (self.movie_emb @ q_emb) / (m_norm * q_norm)
|
| 68 |
+
|
| 69 |
+
idx = np.argsort(sims)[::-1]
|
| 70 |
+
out = []
|
| 71 |
+
for i in idx:
|
| 72 |
+
if len(out) >= limit:
|
| 73 |
+
break
|
| 74 |
+
if float(sims[i]) < float(min_sim):
|
| 75 |
+
break
|
| 76 |
+
|
| 77 |
+
r = self.movies.iloc[i]
|
| 78 |
+
out.append({
|
| 79 |
+
"movieId": int(r["movieId"]),
|
| 80 |
+
"title": str(r["title"]),
|
| 81 |
+
"genres": r.get("genres", ""),
|
| 82 |
+
"similarity": float(sims[i]),
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
return out
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _user_embedding(self, genres, liked_movie_ids):
|
| 89 |
+
vecs = []
|
| 90 |
+
for mid in (liked_movie_ids or []):
|
| 91 |
+
idx = self.movie_id2idx.get(int(mid))
|
| 92 |
+
if idx is not None:
|
| 93 |
+
vecs.append(self.movie_emb[idx])
|
| 94 |
+
|
| 95 |
+
if vecs:
|
| 96 |
+
return np.mean(np.stack(vecs, axis=0), axis=0).astype(np.float32)
|
| 97 |
+
|
| 98 |
+
g = [x.strip() for x in (genres or []) if x and str(x).strip()]
|
| 99 |
+
text = "User likes: " + ", ".join(g) if g else "User likes: popular movies"
|
| 100 |
+
u = self.embedder.encode([text], show_progress_bar=False)
|
| 101 |
+
return np.asarray(u[0], dtype=np.float32)
|
| 102 |
+
|
| 103 |
+
def recommend(self, genres=None, liked_movie_ids=None, top_k=10):
|
| 104 |
+
uvec = self._user_embedding(genres or [], liked_movie_ids or [])
|
| 105 |
+
U = np.repeat(uvec[None, :], self.movie_emb.shape[0], axis=0)
|
| 106 |
+
|
| 107 |
+
with torch.no_grad():
|
| 108 |
+
u = torch.from_numpy(U).to(DEVICE)
|
| 109 |
+
m = torch.from_numpy(self.movie_emb).to(DEVICE)
|
| 110 |
+
scores = torch.sigmoid(self.model(u, m)).cpu().numpy()
|
| 111 |
+
|
| 112 |
+
idx = np.argsort(scores)[::-1][:top_k]
|
| 113 |
+
out = self.movies.iloc[idx].copy()
|
| 114 |
+
out["score"] = scores[idx]
|
| 115 |
+
return out
|
backend/main.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
from typing import List, Literal, Optional
|
| 7 |
+
|
| 8 |
+
from engines.movies_engine import MoviesEngine
|
| 9 |
+
from engines.cards_engine import CardsEngine
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
app = FastAPI()
|
| 13 |
+
|
| 14 |
+
app.add_middleware(
|
| 15 |
+
CORSMiddleware,
|
| 16 |
+
allow_origins=[
|
| 17 |
+
"http://localhost:5173",
|
| 18 |
+
"http://127.0.0.1:5173",
|
| 19 |
+
],
|
| 20 |
+
allow_credentials=True,
|
| 21 |
+
allow_methods=["*"],
|
| 22 |
+
allow_headers=["*"],
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 26 |
+
MOVIE_DIR = os.path.join(BASE_DIR, "models", "movies")
|
| 27 |
+
CARD_DIR = os.path.join(BASE_DIR, "models", "cards")
|
| 28 |
+
|
| 29 |
+
movies_engine = MoviesEngine(MOVIE_DIR)
|
| 30 |
+
cards_engine = CardsEngine(CARD_DIR)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@app.get("/api/health")
|
| 34 |
+
def health():
|
| 35 |
+
return {"status": "ok"}
|
| 36 |
+
|
| 37 |
+
class MovieRecommendReq(BaseModel):
|
| 38 |
+
genres: List[str] = []
|
| 39 |
+
liked_movie_ids: List[int] = []
|
| 40 |
+
top_k: int = Field(10, ge=1, le=50)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class MovieRec(BaseModel):
|
| 44 |
+
movieId: int
|
| 45 |
+
title: str
|
| 46 |
+
genres: Optional[str] = None
|
| 47 |
+
score: float
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@app.get("/api/movies/search")
|
| 51 |
+
def search_movies(q: str):
|
| 52 |
+
return movies_engine.search(q, limit=10)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@app.post("/api/movies/recommend", response_model=List[MovieRec])
|
| 56 |
+
def recommend_movies(req: MovieRecommendReq):
|
| 57 |
+
df = movies_engine.recommend(
|
| 58 |
+
genres=req.genres,
|
| 59 |
+
liked_movie_ids=req.liked_movie_ids,
|
| 60 |
+
top_k=req.top_k,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
out = []
|
| 64 |
+
for _, r in df.iterrows():
|
| 65 |
+
out.append(
|
| 66 |
+
MovieRec(
|
| 67 |
+
movieId=int(r["movieId"]),
|
| 68 |
+
title=str(r["title"]),
|
| 69 |
+
genres=str(r.get("genres", "")) if r.get("genres", None) is not None else None,
|
| 70 |
+
score=float(r["score"]),
|
| 71 |
+
)
|
| 72 |
+
)
|
| 73 |
+
return out
|
| 74 |
+
|
| 75 |
+
class CardRecommendReq(BaseModel):
|
| 76 |
+
credit_score: int = Field(..., ge=300, le=850)
|
| 77 |
+
annual_income: float = Field(..., gt=0)
|
| 78 |
+
|
| 79 |
+
carry_balance: bool = False
|
| 80 |
+
travel_abroad: bool = False
|
| 81 |
+
no_annual_fee: bool = False
|
| 82 |
+
balance_transfer: bool = False
|
| 83 |
+
|
| 84 |
+
rewards_pref: Literal["cashback", "travel", "points", "none"] = "cashback"
|
| 85 |
+
|
| 86 |
+
spend_groceries: float = 300
|
| 87 |
+
spend_dining: float = 250
|
| 88 |
+
spend_gas: float = 120
|
| 89 |
+
spend_travel: float = 80
|
| 90 |
+
spend_online: float = 200
|
| 91 |
+
|
| 92 |
+
top_k: int = Field(10, ge=1, le=50)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class CardRec(BaseModel):
|
| 96 |
+
institution: Optional[str] = None
|
| 97 |
+
product: str
|
| 98 |
+
website: Optional[str] = None
|
| 99 |
+
phone: Optional[str] = None
|
| 100 |
+
score: float
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@app.post("/api/cards/recommend", response_model=List[CardRec])
|
| 104 |
+
def recommend_cards(req: CardRecommendReq):
|
| 105 |
+
df = cards_engine.recommend(req.model_dump(), top_k=req.top_k)
|
| 106 |
+
|
| 107 |
+
out = []
|
| 108 |
+
for _, r in df.iterrows():
|
| 109 |
+
institution = str(r.get("Institution Name", "")).strip()
|
| 110 |
+
website = str(r.get("Website for Consumer", "")).strip()
|
| 111 |
+
phone = str(r.get("Telephone Number for Consumers", "")).strip()
|
| 112 |
+
|
| 113 |
+
out.append(
|
| 114 |
+
CardRec(
|
| 115 |
+
institution=institution if institution else None,
|
| 116 |
+
product=str(r.get("Product Name", "")),
|
| 117 |
+
website=website if website else None,
|
| 118 |
+
phone=phone if phone else None,
|
| 119 |
+
score=float(r["score"]),
|
| 120 |
+
)
|
| 121 |
+
)
|
| 122 |
+
return out
|
| 123 |
+
|
| 124 |
+
from fastapi.staticfiles import StaticFiles
|
| 125 |
+
|
| 126 |
+
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
| 127 |
+
if os.path.isdir(STATIC_DIR):
|
| 128 |
+
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
backend/models/cards/card_item_features.npy
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e18814d3e7eca0c511d8a2358bb1f656e2e28f0c8583602a8030c60512e2e6a0
|
| 3 |
+
size 883088
|
backend/models/cards/cards_meta.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/models/cards/cards_two_tower.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:aa23b1d5e1cecf4e831d2cc0aaf84d40f51e3245961a520c928fb459dc0a1eed
|
| 3 |
+
size 477519
|
backend/models/cards/num_scaler.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:10fbbed9e767cdd63536c8d1d40bca5d8e6507dd08df07db8ae2d02b5dbd680b
|
| 3 |
+
size 759
|
backend/models/movies/idx2movie_id.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:99f333078ad4768677e13c1cb969eebfd3ea5371bc35083f8911c7229b7d658b
|
| 3 |
+
size 718619
|
backend/models/movies/idx2user_id.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ffb6b556abfce977b8ca9c249c40ca16f4a736e410dae4cfa11a74a4ed725a0d
|
| 3 |
+
size 29483
|
backend/models/movies/movie_id2idx.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:51d80f13fdf0cc4e94e43add3f585a89f160e9ffc2e867429ca8c5b66b5e11dd
|
| 3 |
+
size 718619
|
backend/models/movies/movie_ranker.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:74709c7b4f29088b6a0e469d524b57fe4872b28dd91a43b57cc9219033b863de
|
| 3 |
+
size 1054801
|
backend/models/movies/movies_meta.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/models/movies/user_emb_train.npy
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6bcc521abdbca3b3ee25679d89f84f6aefa3eee38c186846d7920efa168ef08e
|
| 3 |
+
size 7672448
|
backend/models/movies/user_id2idx.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e641ead7a92b3d60c1c29f3a0bff59cb17971100b3f00b2e05e3cc206243f396
|
| 3 |
+
size 29483
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn[standard]==0.30.6
|
| 3 |
+
pydantic==2.9.2
|
| 4 |
+
numpy==1.26.4
|
| 5 |
+
pandas==2.2.2
|
| 6 |
+
scikit-learn==1.5.2
|
| 7 |
+
joblib==1.4.2
|
| 8 |
+
torch==2.4.1
|
| 9 |
+
sentence-transformers==3.0.1
|
backend/train_cards.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import numpy as np
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import joblib
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn as nn
|
| 7 |
+
from torch.utils.data import Dataset, DataLoader
|
| 8 |
+
from sentence_transformers import SentenceTransformer
|
| 9 |
+
from sklearn.preprocessing import StandardScaler
|
| 10 |
+
|
| 11 |
+
BACKEND_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 12 |
+
PROJECT_ROOT = os.path.dirname(BACKEND_DIR)
|
| 13 |
+
DATA_DIR = os.path.join(PROJECT_ROOT, "data")
|
| 14 |
+
OUT_DIR = os.path.join(BACKEND_DIR, "models", "cards")
|
| 15 |
+
os.makedirs(OUT_DIR, exist_ok=True)
|
| 16 |
+
|
| 17 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 18 |
+
|
| 19 |
+
def to_float(x):
|
| 20 |
+
if pd.isna(x):
|
| 21 |
+
return np.nan
|
| 22 |
+
s = str(x).strip()
|
| 23 |
+
if not s:
|
| 24 |
+
return np.nan
|
| 25 |
+
s_lower = s.lower()
|
| 26 |
+
if s_lower in {"na", "n/a", "none"}:
|
| 27 |
+
return np.nan
|
| 28 |
+
s = s.replace("%", "")
|
| 29 |
+
try:
|
| 30 |
+
return float(s)
|
| 31 |
+
except Exception:
|
| 32 |
+
return np.nan
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def pick_apr_for_score(row, credit_score):
|
| 36 |
+
if credit_score < 600:
|
| 37 |
+
key = "Purchase APR poor"
|
| 38 |
+
elif credit_score < 720:
|
| 39 |
+
key = "Purchase APR good"
|
| 40 |
+
else:
|
| 41 |
+
key = "Purchase APR great"
|
| 42 |
+
|
| 43 |
+
v = to_float(row.get(key, np.nan))
|
| 44 |
+
if np.isnan(v):
|
| 45 |
+
v = to_float(row.get("Purchase APR median", np.nan))
|
| 46 |
+
if np.isnan(v):
|
| 47 |
+
v = 24.0
|
| 48 |
+
return float(v)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def annual_fee(row):
|
| 52 |
+
v = to_float(row.get("Annual Fee", np.nan))
|
| 53 |
+
if np.isnan(v):
|
| 54 |
+
v = 0.0
|
| 55 |
+
return float(v)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _find_col(row, candidates):
|
| 59 |
+
for c in candidates:
|
| 60 |
+
if c in row and not pd.isna(row[c]):
|
| 61 |
+
return c
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def foreign_fee_pct(row):
|
| 66 |
+
col = _find_col(row, [
|
| 67 |
+
"Foreign Transaction Fee (%)",
|
| 68 |
+
"Foreign Transaction Fee (%) ",
|
| 69 |
+
"Foreign Transaction Fee (%)\ufeff",
|
| 70 |
+
])
|
| 71 |
+
if col is None:
|
| 72 |
+
return 0.0
|
| 73 |
+
v = to_float(row.get(col, np.nan))
|
| 74 |
+
if np.isnan(v):
|
| 75 |
+
return 0.0
|
| 76 |
+
return float(v)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def has_balance_transfer(row):
|
| 80 |
+
s = str(row.get("Balance Transfer Offered?", "")).strip().lower()
|
| 81 |
+
return 1.0 if s in {"yes", "y", "true", "1"} else 0.0
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def bt_length_months(row):
|
| 85 |
+
v = to_float(row.get("Median Length of Balance Transfer APR", np.nan))
|
| 86 |
+
if np.isnan(v):
|
| 87 |
+
v = 0.0
|
| 88 |
+
return float(v)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def intro_apr_months(row):
|
| 92 |
+
v = to_float(row.get("Median Length of Introductory APR", np.nan))
|
| 93 |
+
if np.isnan(v):
|
| 94 |
+
v = 0.0
|
| 95 |
+
return float(v)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def build_card_text(df):
|
| 99 |
+
inst = df.get("Institution Name", pd.Series([""] * len(df))).fillna("").astype(str)
|
| 100 |
+
name = df.get("Product Name", pd.Series([""] * len(df))).fillna("").astype(str)
|
| 101 |
+
rewards = df.get("Rewards", pd.Series([""] * len(df))).fillna("").astype(str)
|
| 102 |
+
features = df.get("Card Features", pd.Series([""] * len(df))).fillna("").astype(str)
|
| 103 |
+
services = df.get("Services", pd.Series([""] * len(df))).fillna("").astype(str)
|
| 104 |
+
return (inst + " | " + name + " | " + rewards + " | " + features + " | " + services).tolist()
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def sample_user(rng):
|
| 108 |
+
credit_score = int(rng.integers(520, 821))
|
| 109 |
+
income = float(
|
| 110 |
+
rng.choice(
|
| 111 |
+
[35000, 50000, 70000, 90000, 120000, 160000],
|
| 112 |
+
p=[0.12, 0.18, 0.24, 0.20, 0.16, 0.10],
|
| 113 |
+
)
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
carry_balance = float(rng.choice([0, 1], p=[0.55, 0.45]))
|
| 117 |
+
travel_abroad = float(rng.choice([0, 1], p=[0.75, 0.25]))
|
| 118 |
+
wants_no_fee = float(rng.choice([0, 1], p=[0.55, 0.45]))
|
| 119 |
+
wants_balance_transfer = float(rng.choice([0, 1], p=[0.65, 0.35]))
|
| 120 |
+
|
| 121 |
+
rewards_pref = rng.choice(["cashback", "travel", "points", "none"], p=[0.45, 0.20, 0.25, 0.10])
|
| 122 |
+
|
| 123 |
+
groceries = float(rng.integers(50, 600))
|
| 124 |
+
dining = float(rng.integers(50, 600))
|
| 125 |
+
gas = float(rng.integers(0, 300))
|
| 126 |
+
travel = float(rng.integers(0, 500))
|
| 127 |
+
online = float(rng.integers(50, 600))
|
| 128 |
+
|
| 129 |
+
return {
|
| 130 |
+
"credit_score": credit_score,
|
| 131 |
+
"income": income,
|
| 132 |
+
"carry_balance": carry_balance,
|
| 133 |
+
"travel_abroad": travel_abroad,
|
| 134 |
+
"wants_no_fee": wants_no_fee,
|
| 135 |
+
"wants_balance_transfer": wants_balance_transfer,
|
| 136 |
+
"rewards_pref": rewards_pref,
|
| 137 |
+
"groceries": groceries,
|
| 138 |
+
"dining": dining,
|
| 139 |
+
"gas": gas,
|
| 140 |
+
"travel": travel,
|
| 141 |
+
"online": online,
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def user_vector(u):
|
| 146 |
+
pref_map = {"cashback": 0, "travel": 1, "points": 2, "none": 3}
|
| 147 |
+
onehot = np.zeros(4, dtype=np.float32)
|
| 148 |
+
onehot[pref_map[u["rewards_pref"]]] = 1.0
|
| 149 |
+
|
| 150 |
+
income_log = np.log1p(u["income"]) / 12.0
|
| 151 |
+
score_norm = (u["credit_score"] - 300.0) / 550.0
|
| 152 |
+
|
| 153 |
+
spend = np.array([u["groceries"], u["dining"], u["gas"], u["travel"], u["online"]], dtype=np.float32)
|
| 154 |
+
spend = spend / (spend.sum() + 1e-6)
|
| 155 |
+
|
| 156 |
+
x = np.concatenate(
|
| 157 |
+
[
|
| 158 |
+
np.array(
|
| 159 |
+
[
|
| 160 |
+
score_norm,
|
| 161 |
+
income_log,
|
| 162 |
+
u["carry_balance"],
|
| 163 |
+
u["travel_abroad"],
|
| 164 |
+
u["wants_no_fee"],
|
| 165 |
+
u["wants_balance_transfer"],
|
| 166 |
+
],
|
| 167 |
+
dtype=np.float32,
|
| 168 |
+
),
|
| 169 |
+
onehot,
|
| 170 |
+
spend,
|
| 171 |
+
]
|
| 172 |
+
)
|
| 173 |
+
return x.astype(np.float32)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def teacher_utility(u, row):
|
| 177 |
+
apr = pick_apr_for_score(row, u["credit_score"])
|
| 178 |
+
af = annual_fee(row)
|
| 179 |
+
fx = foreign_fee_pct(row)
|
| 180 |
+
bt_ok = has_balance_transfer(row)
|
| 181 |
+
bt_len = bt_length_months(row)
|
| 182 |
+
intro_len = intro_apr_months(row)
|
| 183 |
+
|
| 184 |
+
text = (str(row.get("Rewards", "")) + " " + str(row.get("Card Features", ""))).lower()
|
| 185 |
+
|
| 186 |
+
rewards_boost = 0.0
|
| 187 |
+
if u["rewards_pref"] == "cashback" and "cash" in text:
|
| 188 |
+
rewards_boost += 0.8
|
| 189 |
+
if u["rewards_pref"] == "travel" and ("travel" in text or "air" in text or "hotel" in text):
|
| 190 |
+
rewards_boost += 0.8
|
| 191 |
+
if u["rewards_pref"] == "points" and ("points" in text or "miles" in text):
|
| 192 |
+
rewards_boost += 0.8
|
| 193 |
+
|
| 194 |
+
apr_pen = apr / 30.0 if u["carry_balance"] > 0.5 else apr / 80.0
|
| 195 |
+
fee_pen = af / 300.0 if u["wants_no_fee"] > 0.5 else af / 800.0
|
| 196 |
+
fx_pen = (fx / 5.0) if u["travel_abroad"] > 0.5 else (fx / 20.0)
|
| 197 |
+
|
| 198 |
+
bt_boost = 0.0
|
| 199 |
+
if u["wants_balance_transfer"] > 0.5:
|
| 200 |
+
bt_boost += 0.6 * bt_ok + 0.02 * bt_len + 0.015 * intro_len
|
| 201 |
+
|
| 202 |
+
spend_boost = 0.0
|
| 203 |
+
if u["groceries"] > 250 and "grocery" in text:
|
| 204 |
+
spend_boost += 0.2
|
| 205 |
+
if u["dining"] > 250 and ("dining" in text or "restaurant" in text):
|
| 206 |
+
spend_boost += 0.2
|
| 207 |
+
if u["travel"] > 200 and "travel" in text:
|
| 208 |
+
spend_boost += 0.2
|
| 209 |
+
|
| 210 |
+
return float(rewards_boost + spend_boost + bt_boost - (apr_pen + fee_pen + fx_pen))
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
class TwoTower(nn.Module):
|
| 214 |
+
def __init__(self, user_dim, item_dim, hidden=128):
|
| 215 |
+
super().__init__()
|
| 216 |
+
self.user = nn.Sequential(
|
| 217 |
+
nn.Linear(user_dim, hidden),
|
| 218 |
+
nn.ReLU(),
|
| 219 |
+
nn.Linear(hidden, hidden),
|
| 220 |
+
nn.ReLU(),
|
| 221 |
+
)
|
| 222 |
+
self.item = nn.Sequential(
|
| 223 |
+
nn.Linear(item_dim, hidden),
|
| 224 |
+
nn.ReLU(),
|
| 225 |
+
nn.Linear(hidden, hidden),
|
| 226 |
+
nn.ReLU(),
|
| 227 |
+
)
|
| 228 |
+
self.head = nn.Sequential(
|
| 229 |
+
nn.Linear(hidden * 2, hidden),
|
| 230 |
+
nn.ReLU(),
|
| 231 |
+
nn.Linear(hidden, 1),
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
def forward(self, u, x):
|
| 235 |
+
ue = self.user(u)
|
| 236 |
+
ie = self.item(x)
|
| 237 |
+
z = torch.cat([ue, ie], dim=-1)
|
| 238 |
+
return self.head(z).squeeze(-1)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
class PairDataset(Dataset):
|
| 242 |
+
def __init__(self, U, X, y):
|
| 243 |
+
self.U = U
|
| 244 |
+
self.X = X
|
| 245 |
+
self.y = y
|
| 246 |
+
|
| 247 |
+
def __len__(self):
|
| 248 |
+
return len(self.y)
|
| 249 |
+
|
| 250 |
+
def __getitem__(self, i):
|
| 251 |
+
return (
|
| 252 |
+
torch.from_numpy(self.U[i]),
|
| 253 |
+
torch.from_numpy(self.X[i]),
|
| 254 |
+
torch.tensor(self.y[i], dtype=torch.float32),
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def main():
|
| 259 |
+
csv_path = os.path.join(DATA_DIR, "tccp_cards.csv")
|
| 260 |
+
df = pd.read_csv(csv_path, low_memory=False)
|
| 261 |
+
|
| 262 |
+
df = df[df["Product Name"].notna()].copy()
|
| 263 |
+
df = df.reset_index(drop=True)
|
| 264 |
+
|
| 265 |
+
embedder = SentenceTransformer("all-MiniLM-L6-v2")
|
| 266 |
+
text_list = build_card_text(df)
|
| 267 |
+
text_emb = embedder.encode(text_list, batch_size=64, show_progress_bar=True)
|
| 268 |
+
text_emb = np.asarray(text_emb, dtype=np.float32)
|
| 269 |
+
|
| 270 |
+
num = []
|
| 271 |
+
for _, row in df.iterrows():
|
| 272 |
+
apr_median = to_float(row.get("Purchase APR median", np.nan))
|
| 273 |
+
if np.isnan(apr_median):
|
| 274 |
+
apr_median = 24.0
|
| 275 |
+
num.append(
|
| 276 |
+
[
|
| 277 |
+
float(apr_median),
|
| 278 |
+
annual_fee(row),
|
| 279 |
+
foreign_fee_pct(row),
|
| 280 |
+
float(has_balance_transfer(row)),
|
| 281 |
+
bt_length_months(row),
|
| 282 |
+
intro_apr_months(row),
|
| 283 |
+
]
|
| 284 |
+
)
|
| 285 |
+
num = np.asarray(num, dtype=np.float32)
|
| 286 |
+
|
| 287 |
+
scaler = StandardScaler()
|
| 288 |
+
num_scaled = scaler.fit_transform(num).astype(np.float32)
|
| 289 |
+
|
| 290 |
+
item_features = np.concatenate([text_emb, num_scaled], axis=1).astype(np.float32)
|
| 291 |
+
|
| 292 |
+
rng = np.random.default_rng(42)
|
| 293 |
+
|
| 294 |
+
users = [sample_user(rng) for _ in range(30000)]
|
| 295 |
+
U, X, y = [], [], []
|
| 296 |
+
|
| 297 |
+
n_items = len(df)
|
| 298 |
+
|
| 299 |
+
for u in users:
|
| 300 |
+
uvec = user_vector(u)
|
| 301 |
+
|
| 302 |
+
utils = np.empty(n_items, dtype=np.float32)
|
| 303 |
+
for i in range(n_items):
|
| 304 |
+
utils[i] = teacher_utility(u, df.iloc[i])
|
| 305 |
+
|
| 306 |
+
top_idx = utils.argsort()[::-1][:5]
|
| 307 |
+
neg_pool = utils.argsort()[:2000] if n_items >= 2000 else utils.argsort()[: max(1, n_items // 3)]
|
| 308 |
+
neg_idx = rng.choice(neg_pool, size=min(20, len(neg_pool)), replace=False)
|
| 309 |
+
|
| 310 |
+
for i in top_idx:
|
| 311 |
+
U.append(uvec)
|
| 312 |
+
X.append(item_features[i])
|
| 313 |
+
y.append(1.0)
|
| 314 |
+
|
| 315 |
+
for i in neg_idx:
|
| 316 |
+
U.append(uvec)
|
| 317 |
+
X.append(item_features[i])
|
| 318 |
+
y.append(0.0)
|
| 319 |
+
|
| 320 |
+
U = np.asarray(U, dtype=np.float32)
|
| 321 |
+
X = np.asarray(X, dtype=np.float32)
|
| 322 |
+
y = np.asarray(y, dtype=np.float32)
|
| 323 |
+
|
| 324 |
+
idx = np.arange(len(y))
|
| 325 |
+
rng.shuffle(idx)
|
| 326 |
+
split = int(len(y) * 0.9)
|
| 327 |
+
tr, va = idx[:split], idx[split:]
|
| 328 |
+
|
| 329 |
+
train_ds = PairDataset(U[tr], X[tr], y[tr])
|
| 330 |
+
val_ds = PairDataset(U[va], X[va], y[va])
|
| 331 |
+
|
| 332 |
+
train_loader = DataLoader(train_ds, batch_size=512, shuffle=True)
|
| 333 |
+
val_loader = DataLoader(val_ds, batch_size=1024, shuffle=False)
|
| 334 |
+
|
| 335 |
+
model = TwoTower(user_dim=U.shape[1], item_dim=X.shape[1], hidden=128).to(DEVICE)
|
| 336 |
+
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
|
| 337 |
+
crit = nn.BCEWithLogitsLoss()
|
| 338 |
+
|
| 339 |
+
def run(loader, train=True):
|
| 340 |
+
model.train(train)
|
| 341 |
+
total, nobs = 0.0, 0
|
| 342 |
+
for u_b, x_b, y_b in loader:
|
| 343 |
+
u_b = u_b.to(DEVICE)
|
| 344 |
+
x_b = x_b.to(DEVICE)
|
| 345 |
+
y_b = y_b.to(DEVICE)
|
| 346 |
+
|
| 347 |
+
with torch.set_grad_enabled(train):
|
| 348 |
+
logit = model(u_b, x_b)
|
| 349 |
+
loss = crit(logit, y_b)
|
| 350 |
+
if train:
|
| 351 |
+
opt.zero_grad()
|
| 352 |
+
loss.backward()
|
| 353 |
+
opt.step()
|
| 354 |
+
|
| 355 |
+
total += loss.item() * y_b.size(0)
|
| 356 |
+
nobs += y_b.size(0)
|
| 357 |
+
|
| 358 |
+
return total / max(1, nobs)
|
| 359 |
+
|
| 360 |
+
for epoch in range(5):
|
| 361 |
+
tr_loss = run(train_loader, True)
|
| 362 |
+
va_loss = run(val_loader, False)
|
| 363 |
+
print(f"epoch {epoch+1} train_loss={tr_loss:.4f} val_loss={va_loss:.4f}")
|
| 364 |
+
|
| 365 |
+
torch.save(model.state_dict(), os.path.join(OUT_DIR, "cards_two_tower.pt"))
|
| 366 |
+
np.save(os.path.join(OUT_DIR, "card_item_features.npy"), item_features)
|
| 367 |
+
joblib.dump(scaler, os.path.join(OUT_DIR, "num_scaler.pkl"))
|
| 368 |
+
|
| 369 |
+
meta_cols = [
|
| 370 |
+
"Institution Name",
|
| 371 |
+
"Product Name",
|
| 372 |
+
"Targeted Credit Tiers",
|
| 373 |
+
"Secured Card",
|
| 374 |
+
"Annual Fee",
|
| 375 |
+
"Purchase APR poor",
|
| 376 |
+
"Purchase APR good",
|
| 377 |
+
"Purchase APR great",
|
| 378 |
+
"Purchase APR median",
|
| 379 |
+
"Foreign Transaction Fee (%)",
|
| 380 |
+
"Website for Consumer",
|
| 381 |
+
"Telephone Number for Consumers",
|
| 382 |
+
"Rewards",
|
| 383 |
+
"Card Features",
|
| 384 |
+
]
|
| 385 |
+
cols = [c for c in meta_cols if c in df.columns]
|
| 386 |
+
meta = df[cols].copy()
|
| 387 |
+
meta.to_csv(os.path.join(OUT_DIR, "cards_meta.csv"), index=False)
|
| 388 |
+
|
| 389 |
+
print("Saved cards model to:", OUT_DIR)
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
if __name__ == "__main__":
|
| 393 |
+
main()
|
backend/train_movies.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import numpy as np
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import joblib
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn as nn
|
| 7 |
+
from torch.utils.data import Dataset, DataLoader
|
| 8 |
+
from sentence_transformers import SentenceTransformer
|
| 9 |
+
from sklearn.model_selection import train_test_split
|
| 10 |
+
|
| 11 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 12 |
+
|
| 13 |
+
def build_movie_text(movies_df):
|
| 14 |
+
title = movies_df["title"].fillna("").astype(str)
|
| 15 |
+
genres = movies_df["genres"].fillna("").astype(str)
|
| 16 |
+
return (title + " | " + genres).tolist()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def build_user_profile_embeddings(ratings_df, movie_id2idx, movie_emb, like_threshold=4.0):
|
| 20 |
+
liked = ratings_df[ratings_df["rating"] >= like_threshold]
|
| 21 |
+
grp = liked.groupby("userId")["movieId"].apply(list)
|
| 22 |
+
|
| 23 |
+
user_ids = grp.index.values
|
| 24 |
+
user_emb = np.zeros((len(user_ids), movie_emb.shape[1]), dtype=np.float32)
|
| 25 |
+
|
| 26 |
+
for i, uid in enumerate(user_ids):
|
| 27 |
+
mids = grp.loc[uid]
|
| 28 |
+
idxs = [movie_id2idx[m] for m in mids if m in movie_id2idx]
|
| 29 |
+
if idxs:
|
| 30 |
+
user_emb[i] = movie_emb[idxs].mean(axis=0)
|
| 31 |
+
else:
|
| 32 |
+
user_emb[i] = 0.0
|
| 33 |
+
|
| 34 |
+
user_id2idx = {int(uid): i for i, uid in enumerate(user_ids)}
|
| 35 |
+
return user_id2idx, user_emb
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class MoviePairDataset(Dataset):
|
| 39 |
+
def __init__(self, df, user_id2idx, movie_id2idx, user_emb, movie_emb):
|
| 40 |
+
df = df[df["userId"].isin(user_id2idx) & df["movieId"].isin(movie_id2idx)].copy()
|
| 41 |
+
|
| 42 |
+
self.u = df["userId"].map(user_id2idx).astype(int).values
|
| 43 |
+
self.m = df["movieId"].map(movie_id2idx).astype(int).values
|
| 44 |
+
self.y = (df["rating"].values >= 4.0).astype(np.float32)
|
| 45 |
+
|
| 46 |
+
self.user_emb = user_emb
|
| 47 |
+
self.movie_emb = movie_emb
|
| 48 |
+
|
| 49 |
+
def __len__(self):
|
| 50 |
+
return len(self.y)
|
| 51 |
+
|
| 52 |
+
def __getitem__(self, i):
|
| 53 |
+
uvec = self.user_emb[self.u[i]]
|
| 54 |
+
mvec = self.movie_emb[self.m[i]]
|
| 55 |
+
y = self.y[i]
|
| 56 |
+
return (
|
| 57 |
+
torch.from_numpy(uvec),
|
| 58 |
+
torch.from_numpy(mvec),
|
| 59 |
+
torch.tensor(y, dtype=torch.float32),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class MovieRanker(nn.Module):
|
| 64 |
+
def __init__(self, dim=384, hidden=256):
|
| 65 |
+
super().__init__()
|
| 66 |
+
self.net = nn.Sequential(
|
| 67 |
+
nn.Linear(dim * 2, hidden),
|
| 68 |
+
nn.ReLU(),
|
| 69 |
+
nn.Linear(hidden, hidden),
|
| 70 |
+
nn.ReLU(),
|
| 71 |
+
nn.Linear(hidden, 1),
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
def forward(self, u, m):
|
| 75 |
+
x = torch.cat([u, m], dim=-1)
|
| 76 |
+
return self.net(x).squeeze(-1)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def run_epoch(model, loader, optimizer, criterion, train=True):
|
| 80 |
+
model.train(train)
|
| 81 |
+
total_loss = 0.0
|
| 82 |
+
n = 0
|
| 83 |
+
|
| 84 |
+
for u, m, y in loader:
|
| 85 |
+
u, m, y = u.to(DEVICE), m.to(DEVICE), y.to(DEVICE)
|
| 86 |
+
|
| 87 |
+
with torch.set_grad_enabled(train):
|
| 88 |
+
logit = model(u, m)
|
| 89 |
+
loss = criterion(logit, y)
|
| 90 |
+
|
| 91 |
+
if train:
|
| 92 |
+
optimizer.zero_grad()
|
| 93 |
+
loss.backward()
|
| 94 |
+
optimizer.step()
|
| 95 |
+
|
| 96 |
+
total_loss += loss.item() * y.size(0)
|
| 97 |
+
n += y.size(0)
|
| 98 |
+
|
| 99 |
+
return total_loss / max(1, n)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def main():
|
| 103 |
+
data_dir = "../data"
|
| 104 |
+
out_dir = "./models/movies"
|
| 105 |
+
|
| 106 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 107 |
+
|
| 108 |
+
ratings = pd.read_csv(os.path.join(data_dir, "ratings.csv"))
|
| 109 |
+
movies = pd.read_csv(os.path.join(data_dir, "movies.csv"))
|
| 110 |
+
|
| 111 |
+
selected_users = sorted(ratings["userId"].unique())[:5000]
|
| 112 |
+
ratings = ratings[ratings["userId"].isin(selected_users)].copy()
|
| 113 |
+
|
| 114 |
+
embedder = SentenceTransformer("all-MiniLM-L6-v2")
|
| 115 |
+
movie_text = build_movie_text(movies)
|
| 116 |
+
movie_emb = embedder.encode(movie_text, batch_size=128, show_progress_bar=True)
|
| 117 |
+
movie_emb = np.asarray(movie_emb, dtype=np.float32)
|
| 118 |
+
|
| 119 |
+
movie_id2idx = {int(mid): i for i, mid in enumerate(movies["movieId"].values)}
|
| 120 |
+
idx2movie_id = {i: mid for mid, i in movie_id2idx.items()}
|
| 121 |
+
|
| 122 |
+
train_df, val_df = train_test_split(
|
| 123 |
+
ratings,
|
| 124 |
+
test_size=0.2,
|
| 125 |
+
random_state=42,
|
| 126 |
+
stratify=(ratings["rating"] >= 4.0).astype(int),
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
user_id2idx, user_emb = build_user_profile_embeddings(train_df, movie_id2idx, movie_emb)
|
| 130 |
+
idx2user_id = {i: uid for uid, i in user_id2idx.items()}
|
| 131 |
+
|
| 132 |
+
train_df = train_df[train_df["userId"].isin(user_id2idx) & train_df["movieId"].isin(movie_id2idx)].copy()
|
| 133 |
+
val_df = val_df[val_df["userId"].isin(user_id2idx) & val_df["movieId"].isin(movie_id2idx)].copy()
|
| 134 |
+
|
| 135 |
+
train_ds = MoviePairDataset(train_df, user_id2idx, movie_id2idx, user_emb, movie_emb)
|
| 136 |
+
val_ds = MoviePairDataset(val_df, user_id2idx, movie_id2idx, user_emb, movie_emb)
|
| 137 |
+
|
| 138 |
+
train_loader = DataLoader(train_ds, batch_size=512, shuffle=True)
|
| 139 |
+
val_loader = DataLoader(val_ds, batch_size=1024, shuffle=False)
|
| 140 |
+
|
| 141 |
+
model = MovieRanker(dim=movie_emb.shape[1], hidden=256).to(DEVICE)
|
| 142 |
+
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
|
| 143 |
+
criterion = nn.BCEWithLogitsLoss()
|
| 144 |
+
|
| 145 |
+
for epoch in range(6):
|
| 146 |
+
tr = run_epoch(model, train_loader, optimizer, criterion, train=True)
|
| 147 |
+
va = run_epoch(model, val_loader, optimizer, criterion, train=False)
|
| 148 |
+
print(f"epoch {epoch+1} train_loss={tr:.4f} val_loss={va:.4f}")
|
| 149 |
+
|
| 150 |
+
torch.save(model.state_dict(), os.path.join(out_dir, "movie_ranker.pt"))
|
| 151 |
+
np.save(os.path.join(out_dir, "movie_emb.npy"), movie_emb)
|
| 152 |
+
np.save(os.path.join(out_dir, "user_emb_train.npy"), user_emb)
|
| 153 |
+
|
| 154 |
+
movies_small = movies[["movieId", "title", "genres"]].copy()
|
| 155 |
+
movies_small.to_csv(os.path.join(out_dir, "movies_meta.csv"), index=False)
|
| 156 |
+
|
| 157 |
+
joblib.dump(movie_id2idx, os.path.join(out_dir, "movie_id2idx.pkl"))
|
| 158 |
+
joblib.dump(idx2movie_id, os.path.join(out_dir, "idx2movie_id.pkl"))
|
| 159 |
+
joblib.dump(user_id2idx, os.path.join(out_dir, "user_id2idx.pkl"))
|
| 160 |
+
joblib.dump(idx2user_id, os.path.join(out_dir, "idx2user_id.pkl"))
|
| 161 |
+
|
| 162 |
+
print("Saved movies model to:", out_dir)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
if __name__ == "__main__":
|
| 166 |
+
main()
|
data/movies.csv
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b37ca1abc7798de741138ed252b62f69f7e37c84b8a8fab1b82d409b4c6c5cc2
|
| 3 |
+
size 4242926
|
data/tccp_cards.csv
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c81ac7a3b3386cfecff1645b89f02a55360a85892901e4b4893cbb95c5747b74
|
| 3 |
+
size 915779
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
frontend/eslint.config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 6 |
+
|
| 7 |
+
export default defineConfig([
|
| 8 |
+
globalIgnores(['dist']),
|
| 9 |
+
{
|
| 10 |
+
files: ['**/*.{js,jsx}'],
|
| 11 |
+
extends: [
|
| 12 |
+
js.configs.recommended,
|
| 13 |
+
reactHooks.configs.flat.recommended,
|
| 14 |
+
reactRefresh.configs.vite,
|
| 15 |
+
],
|
| 16 |
+
languageOptions: {
|
| 17 |
+
ecmaVersion: 2020,
|
| 18 |
+
globals: globals.browser,
|
| 19 |
+
parserOptions: {
|
| 20 |
+
ecmaVersion: 'latest',
|
| 21 |
+
ecmaFeatures: { jsx: true },
|
| 22 |
+
sourceType: 'module',
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
+
rules: {
|
| 26 |
+
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
])
|
frontend/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>frontend</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"react": "^19.2.0",
|
| 14 |
+
"react-dom": "^19.2.0"
|
| 15 |
+
},
|
| 16 |
+
"devDependencies": {
|
| 17 |
+
"@eslint/js": "^9.39.1",
|
| 18 |
+
"@types/react": "^19.2.5",
|
| 19 |
+
"@types/react-dom": "^19.2.3",
|
| 20 |
+
"@vitejs/plugin-react": "^5.1.1",
|
| 21 |
+
"eslint": "^9.39.1",
|
| 22 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 23 |
+
"eslint-plugin-react-refresh": "^0.4.24",
|
| 24 |
+
"globals": "^16.5.0",
|
| 25 |
+
"vite": "^7.2.4"
|
| 26 |
+
}
|
| 27 |
+
}
|
frontend/public/vite.svg
ADDED
|
|
frontend/src/App.css
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#root {
|
| 2 |
+
max-width: 1280px;
|
| 3 |
+
margin: 0 auto;
|
| 4 |
+
padding: 2rem;
|
| 5 |
+
text-align: center;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.logo {
|
| 9 |
+
height: 6em;
|
| 10 |
+
padding: 1.5em;
|
| 11 |
+
will-change: filter;
|
| 12 |
+
transition: filter 300ms;
|
| 13 |
+
}
|
| 14 |
+
.logo:hover {
|
| 15 |
+
filter: drop-shadow(0 0 2em #646cffaa);
|
| 16 |
+
}
|
| 17 |
+
.logo.react:hover {
|
| 18 |
+
filter: drop-shadow(0 0 2em #61dafbaa);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
@keyframes logo-spin {
|
| 22 |
+
from {
|
| 23 |
+
transform: rotate(0deg);
|
| 24 |
+
}
|
| 25 |
+
to {
|
| 26 |
+
transform: rotate(360deg);
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
@media (prefers-reduced-motion: no-preference) {
|
| 31 |
+
a:nth-of-type(2) .logo {
|
| 32 |
+
animation: logo-spin infinite 20s linear;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.card {
|
| 37 |
+
padding: 2em;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.read-the-docs {
|
| 41 |
+
color: #888;
|
| 42 |
+
}
|
frontend/src/App.jsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import NavBar from "./components/NavBar";
|
| 3 |
+
import MoviesPage from "./pages/MoviesPage";
|
| 4 |
+
import CardsPage from "./pages/CardsPage";
|
| 5 |
+
import "./styles.css";
|
| 6 |
+
|
| 7 |
+
export default function App() {
|
| 8 |
+
const [tab, setTab] = useState("movies");
|
| 9 |
+
return (
|
| 10 |
+
<div className="container">
|
| 11 |
+
<NavBar tab={tab} setTab={setTab} />
|
| 12 |
+
{tab === "movies" ? <MoviesPage /> : <CardsPage />}
|
| 13 |
+
</div>
|
| 14 |
+
);
|
| 15 |
+
}
|
frontend/src/assets/react.svg
ADDED
|
|
frontend/src/components/CardForm.jsx
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
|
| 3 |
+
export default function CardForm({ onSubmit }) {
|
| 4 |
+
const [v, setV] = useState({
|
| 5 |
+
credit_score: 720,
|
| 6 |
+
annual_income: 70000,
|
| 7 |
+
carry_balance: false,
|
| 8 |
+
travel_abroad: false,
|
| 9 |
+
no_annual_fee: true,
|
| 10 |
+
balance_transfer: false,
|
| 11 |
+
rewards_pref: "cashback",
|
| 12 |
+
spend_groceries: 300,
|
| 13 |
+
spend_dining: 250,
|
| 14 |
+
spend_gas: 120,
|
| 15 |
+
spend_travel: 80,
|
| 16 |
+
spend_online: 200,
|
| 17 |
+
top_k: 10,
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
function set(name, value) {
|
| 21 |
+
setV((p) => ({ ...p, [name]: value }));
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<form className="form" onSubmit={(e) => { e.preventDefault(); onSubmit(v); }}>
|
| 26 |
+
<div className="cardHeader">
|
| 27 |
+
<h2>Credit Cards</h2>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<div className="grid2">
|
| 31 |
+
<div className="field">
|
| 32 |
+
<label>Credit score</label>
|
| 33 |
+
<input
|
| 34 |
+
type="range"
|
| 35 |
+
min="300"
|
| 36 |
+
max="850"
|
| 37 |
+
value={v.credit_score}
|
| 38 |
+
onChange={(e) => set("credit_score", Number(e.target.value))}
|
| 39 |
+
/>
|
| 40 |
+
<div className="muted small">{v.credit_score}</div>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<div className="field">
|
| 44 |
+
<label>Annual income</label>
|
| 45 |
+
<input
|
| 46 |
+
type="number"
|
| 47 |
+
value={v.annual_income}
|
| 48 |
+
onChange={(e) => set("annual_income", Number(e.target.value))}
|
| 49 |
+
/>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<div className="field">
|
| 53 |
+
<label>Rewards</label>
|
| 54 |
+
<select value={v.rewards_pref} onChange={(e) => set("rewards_pref", e.target.value)}>
|
| 55 |
+
<option value="cashback">Cashback</option>
|
| 56 |
+
<option value="travel">Travel</option>
|
| 57 |
+
<option value="points">Points</option>
|
| 58 |
+
<option value="none">No preference</option>
|
| 59 |
+
</select>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div className="field">
|
| 63 |
+
<label>Top results</label>
|
| 64 |
+
<input type="number" value={v.top_k} min="1" max="50" onChange={(e) => set("top_k", Number(e.target.value))} />
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<div className="grid2">
|
| 69 |
+
<div className="field">
|
| 70 |
+
<label><input type="checkbox" checked={v.carry_balance} onChange={(e) => set("carry_balance", e.target.checked)} /> I carry a balance</label>
|
| 71 |
+
</div>
|
| 72 |
+
<div className="field">
|
| 73 |
+
<label><input type="checkbox" checked={v.travel_abroad} onChange={(e) => set("travel_abroad", e.target.checked)} /> I travel abroad</label>
|
| 74 |
+
</div>
|
| 75 |
+
<div className="field">
|
| 76 |
+
<label><input type="checkbox" checked={v.no_annual_fee} onChange={(e) => set("no_annual_fee", e.target.checked)} /> Prefer no annual fee</label>
|
| 77 |
+
</div>
|
| 78 |
+
<div className="field">
|
| 79 |
+
<label><input type="checkbox" checked={v.balance_transfer} onChange={(e) => set("balance_transfer", e.target.checked)} /> Need balance transfer</label>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<div className="grid2">
|
| 84 |
+
<div className="field">
|
| 85 |
+
<label>Groceries ($/mo)</label>
|
| 86 |
+
<input type="number" value={v.spend_groceries} onChange={(e) => set("spend_groceries", Number(e.target.value))} />
|
| 87 |
+
</div>
|
| 88 |
+
<div className="field">
|
| 89 |
+
<label>Dining ($/mo)</label>
|
| 90 |
+
<input type="number" value={v.spend_dining} onChange={(e) => set("spend_dining", Number(e.target.value))} />
|
| 91 |
+
</div>
|
| 92 |
+
<div className="field">
|
| 93 |
+
<label>Gas ($/mo)</label>
|
| 94 |
+
<input type="number" value={v.spend_gas} onChange={(e) => set("spend_gas", Number(e.target.value))} />
|
| 95 |
+
</div>
|
| 96 |
+
<div className="field">
|
| 97 |
+
<label>Travel ($/mo)</label>
|
| 98 |
+
<input type="number" value={v.spend_travel} onChange={(e) => set("spend_travel", Number(e.target.value))} />
|
| 99 |
+
</div>
|
| 100 |
+
<div className="field">
|
| 101 |
+
<label>Online ($/mo)</label>
|
| 102 |
+
<input type="number" value={v.spend_online} onChange={(e) => set("spend_online", Number(e.target.value))} />
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<button className="primary" type="submit">Show cards</button>
|
| 107 |
+
</form>
|
| 108 |
+
);
|
| 109 |
+
}
|
frontend/src/components/MovieForm.jsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo, useState } from "react";
|
| 2 |
+
|
| 3 |
+
const GENRES = [
|
| 4 |
+
"Action","Adventure","Animation","Comedy","Crime","Documentary","Drama","Fantasy",
|
| 5 |
+
"Horror","Mystery","Romance","Sci-Fi","Thriller"
|
| 6 |
+
];
|
| 7 |
+
|
| 8 |
+
export default function MovieForm({ onSubmit, onPickLikedMovie }) {
|
| 9 |
+
const [selected, setSelected] = useState([]);
|
| 10 |
+
const [search, setSearch] = useState("");
|
| 11 |
+
|
| 12 |
+
const hint = useMemo(() => {
|
| 13 |
+
if (!selected.length) return "Pick up to 3 genres.";
|
| 14 |
+
return `Genres: ${selected.join(", ")}`;
|
| 15 |
+
}, [selected]);
|
| 16 |
+
|
| 17 |
+
function toggle(g) {
|
| 18 |
+
setSelected((prev) => {
|
| 19 |
+
const has = prev.includes(g);
|
| 20 |
+
if (has) return prev.filter(x => x !== g);
|
| 21 |
+
return [...prev, g].slice(0, 3);
|
| 22 |
+
});
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<form
|
| 27 |
+
className="form"
|
| 28 |
+
onSubmit={(e) => {
|
| 29 |
+
e.preventDefault();
|
| 30 |
+
onSubmit({ genres: selected });
|
| 31 |
+
}}
|
| 32 |
+
>
|
| 33 |
+
<div className="cardHeader">
|
| 34 |
+
<h2>Movies</h2>
|
| 35 |
+
<p className="muted">{hint}</p>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<div className="field">
|
| 39 |
+
<label>Optional: add a movie you already like</label>
|
| 40 |
+
<input
|
| 41 |
+
value={search}
|
| 42 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 43 |
+
placeholder='Search title (e.g. "Batman")'
|
| 44 |
+
onKeyDown={(e) => {
|
| 45 |
+
if (e.key === "Enter") {
|
| 46 |
+
e.preventDefault();
|
| 47 |
+
onPickLikedMovie(search);
|
| 48 |
+
}
|
| 49 |
+
}}
|
| 50 |
+
/>
|
| 51 |
+
<button className="primary" type="button" onClick={() => onPickLikedMovie(search)}>
|
| 52 |
+
Search & add
|
| 53 |
+
</button>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<div className="field">
|
| 57 |
+
<label>Genres</label>
|
| 58 |
+
<div className="chips">
|
| 59 |
+
{GENRES.map((g) => (
|
| 60 |
+
<button
|
| 61 |
+
key={g}
|
| 62 |
+
type="button"
|
| 63 |
+
className={selected.includes(g) ? "chip on" : "chip"}
|
| 64 |
+
onClick={() => toggle(g)}
|
| 65 |
+
>
|
| 66 |
+
{g}
|
| 67 |
+
</button>
|
| 68 |
+
))}
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<button className="primary" type="submit">Get recommendations</button>
|
| 73 |
+
</form>
|
| 74 |
+
);
|
| 75 |
+
}
|
frontend/src/components/NavBar.jsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function NavBar({ tab, setTab }) {
|
| 2 |
+
return (
|
| 3 |
+
<header className="topbar">
|
| 4 |
+
<div>
|
| 5 |
+
<div className="title">Recommendation Engine</div>
|
| 6 |
+
<div className="subtitle">Movies and Credit Cards</div>
|
| 7 |
+
</div>
|
| 8 |
+
|
| 9 |
+
<nav className="tabs">
|
| 10 |
+
<button className={tab === "movies" ? "tab active" : "tab"} onClick={() => setTab("movies")}>
|
| 11 |
+
Movies
|
| 12 |
+
</button>
|
| 13 |
+
<button className={tab === "cards" ? "tab active" : "tab"} onClick={() => setTab("cards")}>
|
| 14 |
+
Credit Cards
|
| 15 |
+
</button>
|
| 16 |
+
</nav>
|
| 17 |
+
</header>
|
| 18 |
+
);
|
| 19 |
+
}
|
frontend/src/components/ResultsGrid.jsx
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function ResultsGrid({ items, renderTitle, renderMeta }) {
|
| 2 |
+
if (!items?.length) return null;
|
| 3 |
+
|
| 4 |
+
return (
|
| 5 |
+
<div className="results">
|
| 6 |
+
{items.map((it, idx) => (
|
| 7 |
+
<div className="item" key={idx}>
|
| 8 |
+
<div className="itemTitle">{renderTitle(it)}</div>
|
| 9 |
+
<div className="muted small" style={{ marginTop: 6 }}>
|
| 10 |
+
{renderMeta(it)}
|
| 11 |
+
</div>
|
| 12 |
+
</div>
|
| 13 |
+
))}
|
| 14 |
+
</div>
|
| 15 |
+
);
|
| 16 |
+
}
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
| 3 |
+
line-height: 1.5;
|
| 4 |
+
font-weight: 400;
|
| 5 |
+
|
| 6 |
+
color-scheme: light dark;
|
| 7 |
+
color: rgba(255, 255, 255, 0.87);
|
| 8 |
+
background-color: #242424;
|
| 9 |
+
|
| 10 |
+
font-synthesis: none;
|
| 11 |
+
text-rendering: optimizeLegibility;
|
| 12 |
+
-webkit-font-smoothing: antialiased;
|
| 13 |
+
-moz-osx-font-smoothing: grayscale;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
a {
|
| 17 |
+
font-weight: 500;
|
| 18 |
+
color: #646cff;
|
| 19 |
+
text-decoration: inherit;
|
| 20 |
+
}
|
| 21 |
+
a:hover {
|
| 22 |
+
color: #535bf2;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
body {
|
| 26 |
+
margin: 0;
|
| 27 |
+
display: flex;
|
| 28 |
+
place-items: center;
|
| 29 |
+
min-width: 320px;
|
| 30 |
+
min-height: 100vh;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
h1 {
|
| 34 |
+
font-size: 3.2em;
|
| 35 |
+
line-height: 1.1;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
button {
|
| 39 |
+
border-radius: 8px;
|
| 40 |
+
border: 1px solid transparent;
|
| 41 |
+
padding: 0.6em 1.2em;
|
| 42 |
+
font-size: 1em;
|
| 43 |
+
font-weight: 500;
|
| 44 |
+
font-family: inherit;
|
| 45 |
+
background-color: #1a1a1a;
|
| 46 |
+
cursor: pointer;
|
| 47 |
+
transition: border-color 0.25s;
|
| 48 |
+
}
|
| 49 |
+
button:hover {
|
| 50 |
+
border-color: #646cff;
|
| 51 |
+
}
|
| 52 |
+
button:focus,
|
| 53 |
+
button:focus-visible {
|
| 54 |
+
outline: 4px auto -webkit-focus-ring-color;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
@media (prefers-color-scheme: light) {
|
| 58 |
+
:root {
|
| 59 |
+
color: #213547;
|
| 60 |
+
background-color: #ffffff;
|
| 61 |
+
}
|
| 62 |
+
a:hover {
|
| 63 |
+
color: #747bff;
|
| 64 |
+
}
|
| 65 |
+
button {
|
| 66 |
+
background-color: #f9f9f9;
|
| 67 |
+
}
|
| 68 |
+
}
|
frontend/src/main.jsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.jsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
frontend/src/pages/CardsPage.jsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import CardForm from "../components/CardForm";
|
| 3 |
+
import ResultsGrid from "../components/ResultsGrid";
|
| 4 |
+
|
| 5 |
+
const API = "";
|
| 6 |
+
|
| 7 |
+
export default function CardsPage() {
|
| 8 |
+
const [recs, setRecs] = useState([]);
|
| 9 |
+
const [error, setError] = useState("");
|
| 10 |
+
|
| 11 |
+
async function submit(payload) {
|
| 12 |
+
setError("");
|
| 13 |
+
setRecs([]);
|
| 14 |
+
|
| 15 |
+
try {
|
| 16 |
+
const res = await fetch(`${API}/api/cards/recommend`, {
|
| 17 |
+
method: "POST",
|
| 18 |
+
headers: { "Content-Type": "application/json" },
|
| 19 |
+
body: JSON.stringify(payload),
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
if (!res.ok) throw new Error("Request failed");
|
| 23 |
+
setRecs(await res.json());
|
| 24 |
+
} catch (e) {
|
| 25 |
+
setError(e.message || "Error");
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
return (
|
| 30 |
+
<section className="card">
|
| 31 |
+
<CardForm onSubmit={submit} />
|
| 32 |
+
{error && <div className="muted small" style={{ marginTop: 12 }}>{error}</div>}
|
| 33 |
+
|
| 34 |
+
<ResultsGrid
|
| 35 |
+
items={recs}
|
| 36 |
+
renderTitle={(r) => r.product}
|
| 37 |
+
renderMeta={(r) =>
|
| 38 |
+
`${r.institution || "Institution"} • Score: ${r.score.toFixed(3)}`
|
| 39 |
+
}
|
| 40 |
+
/>
|
| 41 |
+
</section>
|
| 42 |
+
);
|
| 43 |
+
}
|
frontend/src/pages/MoviesPage.jsx
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import MovieForm from "../components/MovieForm";
|
| 3 |
+
import ResultsGrid from "../components/ResultsGrid";
|
| 4 |
+
|
| 5 |
+
const API = "";
|
| 6 |
+
|
| 7 |
+
export default function MoviesPage() {
|
| 8 |
+
const [liked, setLiked] = useState([]);
|
| 9 |
+
const [recs, setRecs] = useState([]);
|
| 10 |
+
const [msg, setMsg] = useState("");
|
| 11 |
+
|
| 12 |
+
async function addLikedMovie(q) {
|
| 13 |
+
const query = (q || "").trim();
|
| 14 |
+
if (!query) return;
|
| 15 |
+
|
| 16 |
+
const res = await fetch(`${API}/api/movies/search?q=${encodeURIComponent(query)}`);
|
| 17 |
+
const hits = await res.json();
|
| 18 |
+
if (!hits.length) {
|
| 19 |
+
setMsg("No matches found.");
|
| 20 |
+
return;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const first = hits[0];
|
| 24 |
+
setLiked((prev) => (prev.some(x => x.movieId === first.movieId) ? prev : [...prev, first]));
|
| 25 |
+
setMsg(`Added: ${first.title}`);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
async function submit({ genres }) {
|
| 29 |
+
setMsg("");
|
| 30 |
+
const body = {
|
| 31 |
+
genres,
|
| 32 |
+
liked_movie_ids: liked.map(x => x.movieId),
|
| 33 |
+
top_k: 10,
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const res = await fetch(`${API}/api/movies/recommend`, {
|
| 37 |
+
method: "POST",
|
| 38 |
+
headers: { "Content-Type": "application/json" },
|
| 39 |
+
body: JSON.stringify(body),
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
const data = await res.json();
|
| 43 |
+
setRecs(data);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<section className="card">
|
| 48 |
+
<MovieForm onSubmit={submit} onPickLikedMovie={addLikedMovie} />
|
| 49 |
+
|
| 50 |
+
{liked.length > 0 && (
|
| 51 |
+
<div style={{ marginTop: 12 }}>
|
| 52 |
+
<div className="muted small">Liked movies:</div>
|
| 53 |
+
<div className="row" style={{ marginTop: 8 }}>
|
| 54 |
+
{liked.map((m) => (
|
| 55 |
+
<span className="badge" key={m.movieId}>{m.title}</span>
|
| 56 |
+
))}
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
)}
|
| 60 |
+
|
| 61 |
+
{msg && <div className="muted small" style={{ marginTop: 12 }}>{msg}</div>}
|
| 62 |
+
|
| 63 |
+
<ResultsGrid
|
| 64 |
+
items={recs}
|
| 65 |
+
renderTitle={(r) => r.title}
|
| 66 |
+
renderMeta={(r) => `Score: ${r.score.toFixed(3)} • ${r.genres || "-"}`}
|
| 67 |
+
/>
|
| 68 |
+
</section>
|
| 69 |
+
);
|
| 70 |
+
}
|
frontend/src/styles.css
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg: #0b0c10;
|
| 3 |
+
--card: #12141b;
|
| 4 |
+
--text: #e9edf1;
|
| 5 |
+
--muted: #9aa3ad;
|
| 6 |
+
--line: #232733;
|
| 7 |
+
--accent: #6ea8ff;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
* { box-sizing: border-box; }
|
| 11 |
+
body {
|
| 12 |
+
margin: 0;
|
| 13 |
+
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
|
| 14 |
+
background: var(--bg);
|
| 15 |
+
color: var(--text);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
.container { max-width: 980px; margin: 0 auto; padding: 24px; }
|
| 19 |
+
.topbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
|
| 20 |
+
.title { font-size: 20px; font-weight: 650; }
|
| 21 |
+
.subtitle { color: var(--muted); font-size: 13px; margin-top: 2px; }
|
| 22 |
+
|
| 23 |
+
.tabs { display: flex; gap: 8px; }
|
| 24 |
+
.tab {
|
| 25 |
+
background: transparent;
|
| 26 |
+
border: 1px solid var(--line);
|
| 27 |
+
color: var(--text);
|
| 28 |
+
padding: 8px 12px;
|
| 29 |
+
border-radius: 10px;
|
| 30 |
+
cursor: pointer;
|
| 31 |
+
}
|
| 32 |
+
.tab.active { border-color: var(--accent); }
|
| 33 |
+
|
| 34 |
+
.card { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 18px; }
|
| 35 |
+
.cardHeader h2 { margin: 0 0 4px 0; }
|
| 36 |
+
.muted { color: var(--muted); }
|
| 37 |
+
.small { font-size: 13px; }
|
| 38 |
+
|
| 39 |
+
.form { margin-top: 14px; display: grid; gap: 12px; }
|
| 40 |
+
.grid2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
| 41 |
+
.field { display: grid; gap: 6px; }
|
| 42 |
+
label { font-size: 13px; color: var(--muted); }
|
| 43 |
+
|
| 44 |
+
input, select {
|
| 45 |
+
background: #0e1016;
|
| 46 |
+
border: 1px solid var(--line);
|
| 47 |
+
color: var(--text);
|
| 48 |
+
padding: 10px 12px;
|
| 49 |
+
border-radius: 12px;
|
| 50 |
+
outline: none;
|
| 51 |
+
}
|
| 52 |
+
input:focus, select:focus { border-color: var(--accent); }
|
| 53 |
+
|
| 54 |
+
.primary {
|
| 55 |
+
background: var(--accent);
|
| 56 |
+
color: #0b0c10;
|
| 57 |
+
border: 0;
|
| 58 |
+
padding: 10px 14px;
|
| 59 |
+
border-radius: 12px;
|
| 60 |
+
cursor: pointer;
|
| 61 |
+
font-weight: 600;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
| 65 |
+
.chip {
|
| 66 |
+
border: 1px solid var(--line);
|
| 67 |
+
background: transparent;
|
| 68 |
+
color: var(--text);
|
| 69 |
+
padding: 6px 10px;
|
| 70 |
+
border-radius: 999px;
|
| 71 |
+
cursor: pointer;
|
| 72 |
+
font-size: 13px;
|
| 73 |
+
}
|
| 74 |
+
.chip.on { border-color: var(--accent); }
|
| 75 |
+
|
| 76 |
+
.results { margin-top: 16px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
| 77 |
+
.item { border: 1px solid var(--line); border-radius: 14px; padding: 12px; background: #0e1016; }
|
| 78 |
+
.itemTitle { font-weight: 650; }
|
| 79 |
+
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
| 80 |
+
.badge { font-size: 12px; color: var(--muted); border: 1px solid var(--line); padding: 4px 8px; border-radius: 999px; }
|
| 81 |
+
|
| 82 |
+
@media (max-width: 720px) {
|
| 83 |
+
.grid2 { grid-template-columns: 1fr; }
|
| 84 |
+
.results { grid-template-columns: 1fr; }
|
| 85 |
+
}
|
frontend/vite.config.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
|
| 4 |
+
// https://vite.dev/config/
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react()],
|
| 7 |
+
})
|