Nagharjun Mathi Mariappan commited on
Commit
8ad64dc
·
0 Parent(s):

Initial commit for Space deployment

Browse files
Files changed (44) hide show
  1. .gitattributes +38 -0
  2. .gitignore +207 -0
  3. Dockerfile +32 -0
  4. LICENSE +21 -0
  5. README.md +5 -0
  6. backend/engines/cards_engine.py +108 -0
  7. backend/engines/movies_engine.py +115 -0
  8. backend/main.py +128 -0
  9. backend/models/cards/card_item_features.npy +3 -0
  10. backend/models/cards/cards_meta.csv +0 -0
  11. backend/models/cards/cards_two_tower.pt +3 -0
  12. backend/models/cards/num_scaler.pkl +3 -0
  13. backend/models/movies/idx2movie_id.pkl +3 -0
  14. backend/models/movies/idx2user_id.pkl +3 -0
  15. backend/models/movies/movie_id2idx.pkl +3 -0
  16. backend/models/movies/movie_ranker.pt +3 -0
  17. backend/models/movies/movies_meta.csv +0 -0
  18. backend/models/movies/user_emb_train.npy +3 -0
  19. backend/models/movies/user_id2idx.pkl +3 -0
  20. backend/requirements.txt +9 -0
  21. backend/train_cards.py +393 -0
  22. backend/train_movies.py +166 -0
  23. data/movies.csv +3 -0
  24. data/tccp_cards.csv +3 -0
  25. frontend/.gitignore +24 -0
  26. frontend/README.md +16 -0
  27. frontend/eslint.config.js +29 -0
  28. frontend/index.html +13 -0
  29. frontend/package-lock.json +0 -0
  30. frontend/package.json +27 -0
  31. frontend/public/vite.svg +1 -0
  32. frontend/src/App.css +42 -0
  33. frontend/src/App.jsx +15 -0
  34. frontend/src/assets/react.svg +1 -0
  35. frontend/src/components/CardForm.jsx +109 -0
  36. frontend/src/components/MovieForm.jsx +75 -0
  37. frontend/src/components/NavBar.jsx +19 -0
  38. frontend/src/components/ResultsGrid.jsx +16 -0
  39. frontend/src/index.css +68 -0
  40. frontend/src/main.jsx +10 -0
  41. frontend/src/pages/CardsPage.jsx +43 -0
  42. frontend/src/pages/MoviesPage.jsx +70 -0
  43. frontend/src/styles.css +85 -0
  44. 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
+ })