Abdallah4z commited on
Commit
a3cafd5
·
1 Parent(s): cabd6cc

Refactor recommendation page and templates

Browse files

- Converted recommend.html to extend a new base.html template for better structure and reusability.
- Added breadcrumb navigation and improved user selection interface with Alpine.js for dynamic updates.
- Implemented a new approach and method selection UI with buttons and dynamic filtering for recommendations.
- Enhanced the results display with tabs for cards and comparison, including loading states and error handling.
- Created macros in macros.html for reusable components like navigation, glass containers, product cards, and loading spinners.
- Introduced a base layout in base.html with theme toggling and toast notifications for user feedback.
- Updated JavaScript logic to handle user interactions and API calls more efficiently.

app.py CHANGED
@@ -92,6 +92,7 @@ def get_product_info(product_id):
92
  @app.route("/")
93
  def index():
94
  return render_template("index.html",
 
95
  users=USER_OPTIONS,
96
  categories=CATEGORIES,
97
  brands=BRANDS,
@@ -101,6 +102,7 @@ def index():
101
  @app.route("/recommend")
102
  def recommend_page():
103
  return render_template("recommend.html",
 
104
  users=USER_OPTIONS,
105
  categories=CATEGORIES,
106
  brands=BRANDS,
@@ -110,6 +112,7 @@ def recommend_page():
110
  @app.route("/evaluate")
111
  def evaluate_page():
112
  return render_template("evaluation.html",
 
113
  users=USER_OPTIONS,
114
  categories=CATEGORIES,
115
  brands=BRANDS,
@@ -297,5 +300,120 @@ def api_evaluate():
297
  return jsonify({"error": str(e)}), 500
298
 
299
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  if __name__ == "__main__":
301
  app.run(debug=True, host="0.0.0.0", port=5000)
 
92
  @app.route("/")
93
  def index():
94
  return render_template("index.html",
95
+ active_page="home",
96
  users=USER_OPTIONS,
97
  categories=CATEGORIES,
98
  brands=BRANDS,
 
102
  @app.route("/recommend")
103
  def recommend_page():
104
  return render_template("recommend.html",
105
+ active_page="recommend",
106
  users=USER_OPTIONS,
107
  categories=CATEGORIES,
108
  brands=BRANDS,
 
112
  @app.route("/evaluate")
113
  def evaluate_page():
114
  return render_template("evaluation.html",
115
+ active_page="evaluate",
116
  users=USER_OPTIONS,
117
  categories=CATEGORIES,
118
  brands=BRANDS,
 
300
  return jsonify({"error": str(e)}), 500
301
 
302
 
303
+ @app.route("/api/products/filter")
304
+ def api_products_filter():
305
+ cat = request.args.get("category")
306
+ brand = request.args.get("brand")
307
+ price_min = request.args.get("price_min", type=float)
308
+ price_max = request.args.get("price_max", type=float)
309
+ q = request.args.get("q", "").lower()
310
+ filtered = products.copy()
311
+ if cat:
312
+ filtered = filtered[filtered["category"] == cat]
313
+ if brand:
314
+ filtered = filtered[filtered["brand"] == brand]
315
+ if price_min is not None:
316
+ filtered = filtered[filtered["price"] >= price_min]
317
+ if price_max is not None:
318
+ filtered = filtered[filtered["price"] <= price_max]
319
+ if q:
320
+ filtered = filtered[filtered["name"].str.lower().str.contains(q, na=False)]
321
+ results = []
322
+ for _, row in filtered.iterrows():
323
+ results.append(get_product_info(row["product_id"]))
324
+ return jsonify({
325
+ "total": len(results),
326
+ "products": results,
327
+ })
328
+
329
+
330
+ @app.route("/api/user/<int:user_id>/preferences", methods=["PUT"])
331
+ def api_update_preferences(user_id):
332
+ data = request.json
333
+ user_idx = users[users["user_id"] == user_id].index
334
+ if user_idx.empty:
335
+ return jsonify({"error": "User not found"}), 404
336
+ if "budget_min" in data:
337
+ users.at[user_idx[0], "budget_min"] = data["budget_min"]
338
+ if "budget_max" in data:
339
+ users.at[user_idx[0], "budget_max"] = data["budget_max"]
340
+ prefs = get_user_preferences(users, user_id)
341
+ for u in USER_OPTIONS:
342
+ if u["id"] == user_id:
343
+ u["budget_min"] = float(prefs.get("budget_min", 0))
344
+ u["budget_max"] = float(prefs.get("budget_max", 999999))
345
+ break
346
+ return jsonify({"success": True, "preferences": prefs})
347
+
348
+
349
+ @app.route("/htmx/recommend", methods=["POST"])
350
+ def htmx_recommend():
351
+ data = request.json or request.form
352
+ user_id = data.get("user_id", type=int)
353
+ approach = data.get("approach", "cf")
354
+ method = data.get("method", "user_based")
355
+ n_recs = data.get("n", 10, type=int)
356
+
357
+ if not user_id:
358
+ return '<div class="empty-state"><div class="empty-icon">⚠️</div><p>Please select a user first.</p></div>'
359
+
360
+ user_rated = get_user_rated_items(user_id)
361
+ prefs = get_user_preferences(users, user_id)
362
+
363
+ try:
364
+ if approach == "cf":
365
+ recs = cf.recommend(method, user_id, n_recommendations=n_recs)
366
+ elif approach == "content":
367
+ recs = cb.recommend(method, user_profile_items=user_rated, preferences=prefs, n_recommendations=n_recs)
368
+ elif approach == "knowledge":
369
+ constraints = {
370
+ "budget_min": prefs.get("budget_min", 0),
371
+ "budget_max": prefs.get("budget_max", 999999),
372
+ "category": list(prefs.get("preferred_categories", set())),
373
+ "brand": list(prefs.get("favorite_brands", set())),
374
+ }
375
+ recs = kb.recommend(method, constraints=constraints, preferences=prefs, n_recommendations=n_recs)
376
+ else:
377
+ return f'<div class="empty-state"><div class="empty-icon">❌</div><p>Unknown approach: {approach}</p></div>'
378
+ except Exception as e:
379
+ return f'<div class="empty-state"><div class="empty-icon">❌</div><p>{str(e)}</p></div>'
380
+
381
+ if not recs:
382
+ return '<div class="empty-state"><div class="empty-icon">📭</div><p>No recommendations found.</p></div>'
383
+
384
+ html = '<div class="product-grid">'
385
+ for pid, score in recs:
386
+ product = get_product_info(pid)
387
+ if not product:
388
+ continue
389
+ explanation = "Recommended based on your preferences."
390
+ html += f'''
391
+ <div class="product-card">
392
+ <div class="product-icon">{get_category_icon(product["category"])}</div>
393
+ <div class="product-name">{product["name"]}</div>
394
+ <div class="product-meta">{product["brand"]} · {product["subcategory"]}</div>
395
+ <div class="compact-row">
396
+ <div class="product-price">${product["price"]:.2f}</div>
397
+ <div class="product-rating">{stars_html(product["avg_rating"])} {product["avg_rating"]}</div>
398
+ </div>
399
+ <div class="product-explanation">{explanation}</div>
400
+ </div>'''
401
+ html += '</div>'
402
+ return html
403
+
404
+
405
+ def get_category_icon(category):
406
+ icons = {
407
+ "Electronics": "💻", "Clothing": "👕", "Home & Kitchen": "🏠",
408
+ "Books": "📚", "Sports": "⚽", "Beauty": "💄", "Toys": "🧸", "Automotive": "🚗"
409
+ }
410
+ return icons.get(category, "📦")
411
+
412
+
413
+ def stars_html(rating):
414
+ f = int(rating)
415
+ return "★" * f + "☆" * (5 - f)
416
+
417
+
418
  if __name__ == "__main__":
419
  app.run(debug=True, host="0.0.0.0", port=5000)
recommender/__pycache__/__init__.cpython-310.pyc CHANGED
Binary files a/recommender/__pycache__/__init__.cpython-310.pyc and b/recommender/__pycache__/__init__.cpython-310.pyc differ
 
recommender/__pycache__/collaborative.cpython-310.pyc CHANGED
Binary files a/recommender/__pycache__/collaborative.cpython-310.pyc and b/recommender/__pycache__/collaborative.cpython-310.pyc differ
 
recommender/__pycache__/content_based.cpython-310.pyc CHANGED
Binary files a/recommender/__pycache__/content_based.cpython-310.pyc and b/recommender/__pycache__/content_based.cpython-310.pyc differ
 
recommender/__pycache__/evaluation.cpython-310.pyc CHANGED
Binary files a/recommender/__pycache__/evaluation.cpython-310.pyc and b/recommender/__pycache__/evaluation.cpython-310.pyc differ
 
recommender/__pycache__/explainer.cpython-310.pyc CHANGED
Binary files a/recommender/__pycache__/explainer.cpython-310.pyc and b/recommender/__pycache__/explainer.cpython-310.pyc differ
 
recommender/__pycache__/knowledge_based.cpython-310.pyc CHANGED
Binary files a/recommender/__pycache__/knowledge_based.cpython-310.pyc and b/recommender/__pycache__/knowledge_based.cpython-310.pyc differ
 
static/css/style.css CHANGED
@@ -1,547 +1,1452 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  * {
2
- margin: 0;
3
- padding: 0;
4
- box-sizing: border-box;
5
  }
6
 
7
- body {
8
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
9
- background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
10
- min-height: 100vh;
11
- color: #e0e0e0;
12
  }
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  .nav {
15
- background: rgba(255, 255, 255, 0.05);
16
- backdrop-filter: blur(20px);
17
- -webkit-backdrop-filter: blur(20px);
18
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
19
- padding: 1rem 2rem;
20
- display: flex;
21
- align-items: center;
22
- justify-content: space-between;
 
 
 
 
23
  }
24
 
25
  .nav-brand {
26
- font-size: 1.3rem;
27
- font-weight: 700;
28
- color: #fff;
29
- text-decoration: none;
30
- letter-spacing: -0.5px;
 
 
 
31
  }
32
 
33
  .nav-brand span {
34
- color: #6C63FF;
 
 
 
 
 
 
 
 
 
 
 
35
  }
36
 
37
  .nav-links {
38
- display: flex;
39
- gap: 1.5rem;
40
  }
41
 
42
  .nav-links a {
43
- color: rgba(255, 255, 255, 0.7);
44
- text-decoration: none;
45
- font-size: 0.9rem;
46
- font-weight: 500;
47
- transition: color 0.2s;
48
- padding: 0.5rem 0;
 
49
  }
50
 
51
- .nav-links a:hover,
52
- .nav-links a.active {
53
- color: #fff;
 
 
 
 
 
 
 
 
 
 
 
54
  }
55
 
56
  .nav-links a.active {
57
- border-bottom: 2px solid #6C63FF;
 
 
 
 
58
  }
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  .container {
61
- max-width: 1200px;
62
- margin: 0 auto;
63
- padding: 2rem;
 
 
 
 
 
 
64
  }
65
 
 
66
  .glass {
67
- background: rgba(255, 255, 255, 0.06);
68
- backdrop-filter: blur(20px);
69
- -webkit-backdrop-filter: blur(20px);
70
- border: 1px solid rgba(255, 255, 255, 0.1);
71
- border-radius: 16px;
72
- padding: 1.5rem;
73
- margin-bottom: 1.5rem;
 
 
 
 
 
74
  }
75
 
76
  .glass-header {
77
- font-size: 1.1rem;
78
- font-weight: 600;
79
- color: #fff;
80
- margin-bottom: 1rem;
81
- }
82
-
83
- select, input[type="number"] {
84
- background: rgba(255, 255, 255, 0.08);
85
- border: 1px solid rgba(255, 255, 255, 0.15);
86
- border-radius: 8px;
87
- padding: 0.65rem 1rem;
88
- color: #fff;
89
- font-size: 0.9rem;
90
- width: 100%;
91
- outline: none;
92
- transition: border-color 0.2s;
93
- appearance: none;
94
- -webkit-appearance: none;
 
 
95
  }
96
 
97
  select:hover, input:hover {
98
- border-color: rgba(108, 99, 255, 0.5);
99
  }
100
 
101
  select:focus, input:focus {
102
- border-color: #6C63FF;
 
 
 
 
 
 
 
 
103
  }
104
 
105
  select option {
106
- background: #24243e;
107
- color: #fff;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  }
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  .btn {
111
- padding: 0.7rem 1.5rem;
112
- border: none;
113
- border-radius: 8px;
114
- font-size: 0.9rem;
115
- font-weight: 600;
116
- cursor: pointer;
117
- transition: all 0.3s ease;
118
- font-family: inherit;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  }
120
 
121
  .btn-primary {
122
- background: linear-gradient(135deg, #6C63FF, #FF6584);
123
- color: #fff;
124
  }
125
 
126
- .btn-primary:hover {
127
- transform: translateY(-2px);
128
- box-shadow: 0 8px 25px rgba(108, 99, 255, 0.4);
129
  }
130
 
131
  .btn-secondary {
132
- background: rgba(255, 255, 255, 0.1);
133
- color: #fff;
134
- border: 1px solid rgba(255, 255, 255, 0.15);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  }
136
 
137
- .btn-secondary:hover {
138
- background: rgba(255, 255, 255, 0.15);
 
139
  }
140
 
141
  .btn-group {
142
- display: flex;
143
- gap: 0.5rem;
144
- flex-wrap: wrap;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  }
146
 
147
  .btn-approach {
148
- background: rgba(255, 255, 255, 0.06);
149
- border: 1px solid rgba(255, 255, 255, 0.1);
150
- border-radius: 8px;
151
- padding: 0.75rem 1.2rem;
152
- color: rgba(255, 255, 255, 0.7);
153
- cursor: pointer;
154
- font-size: 0.85rem;
155
- font-weight: 500;
156
- transition: all 0.3s;
157
- font-family: inherit;
158
- flex: 1;
159
- min-width: 120px;
160
- text-align: center;
161
  }
162
 
163
  .btn-approach:hover {
164
- background: rgba(108, 99, 255, 0.15);
165
- border-color: #6C63FF;
166
- color: #fff;
 
167
  }
168
 
169
  .btn-approach.active {
170
- background: linear-gradient(135deg, rgba(108, 99, 255, 0.3), rgba(255, 101, 132, 0.2));
171
- border-color: #6C63FF;
172
- color: #fff;
 
173
  }
174
 
175
  .btn-approach .icon {
176
- font-size: 1.5rem;
177
- display: block;
178
- margin-bottom: 0.3rem;
179
  }
180
 
181
  .btn-method {
182
- background: rgba(255, 255, 255, 0.04);
183
- border: 1px solid rgba(255, 255, 255, 0.08);
184
- border-radius: 6px;
185
- padding: 0.4rem 0.8rem;
186
- color: rgba(255, 255, 255, 0.6);
187
- cursor: pointer;
188
- font-size: 0.8rem;
189
- transition: all 0.2s;
190
- font-family: inherit;
191
  }
192
 
193
  .btn-method:hover {
194
- background: rgba(255, 255, 255, 0.08);
195
- color: #fff;
196
  }
197
 
198
  .btn-method.active {
199
- background: rgba(108, 99, 255, 0.2);
200
- border-color: #6C63FF;
201
- color: #fff;
202
  }
203
 
 
204
  .profile-card {
205
- display: flex;
206
- gap: 2rem;
207
- align-items: center;
208
- flex-wrap: wrap;
209
  }
210
 
211
  .profile-avatar {
212
- width: 64px;
213
- height: 64px;
214
- border-radius: 50%;
215
- background: linear-gradient(135deg, #6C63FF, #FF6584);
216
- display: flex;
217
- align-items: center;
218
- justify-content: center;
219
- font-size: 1.5rem;
220
- font-weight: 700;
221
- color: #fff;
222
- flex-shrink: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  }
224
 
225
  .profile-info {
226
- flex: 1;
227
  }
228
 
229
  .profile-name {
230
- font-size: 1.1rem;
231
- font-weight: 600;
232
- color: #fff;
233
  }
234
 
235
  .profile-detail {
236
- font-size: 0.85rem;
237
- color: rgba(255, 255, 255, 0.6);
238
- margin-top: 0.2rem;
239
  }
240
 
241
  .profile-tags {
242
- display: flex;
243
- gap: 0.4rem;
244
- flex-wrap: wrap;
245
  }
246
 
 
247
  .tag {
248
- background: rgba(108, 99, 255, 0.2);
249
- border: 1px solid rgba(108, 99, 255, 0.3);
250
- border-radius: 4px;
251
- padding: 0.15rem 0.5rem;
252
- font-size: 0.75rem;
253
- color: #a99bff;
 
 
 
 
 
 
254
  }
255
 
 
256
  .row {
257
- display: grid;
258
- grid-template-columns: 1fr 1fr;
259
- gap: 1.5rem;
260
  }
261
 
262
  @media (max-width: 768px) {
263
- .row {
264
- grid-template-columns: 1fr;
265
- }
266
  }
267
 
 
268
  .product-grid {
269
- display: grid;
270
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
271
- gap: 1rem;
272
  }
273
 
274
  .product-card {
275
- background: rgba(255, 255, 255, 0.04);
276
- border: 1px solid rgba(255, 255, 255, 0.08);
277
- border-radius: 12px;
278
- padding: 1rem;
279
- transition: all 0.3s;
280
- cursor: default;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  }
282
 
283
  .product-card:hover {
284
- transform: translateY(-3px);
285
- background: rgba(255, 255, 255, 0.07);
286
- border-color: rgba(108, 99, 255, 0.3);
287
- box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  }
289
 
290
  .product-icon {
291
- width: 48px;
292
- height: 48px;
293
- border-radius: 10px;
294
- background: linear-gradient(135deg, rgba(108, 99, 255, 0.2), rgba(255, 101, 132, 0.1));
295
- display: flex;
296
- align-items: center;
297
- justify-content: center;
298
- font-size: 1.5rem;
299
- margin-bottom: 0.75rem;
300
  }
301
 
302
  .product-name {
303
- font-size: 0.95rem;
304
- font-weight: 600;
305
- color: #fff;
306
- margin-bottom: 0.3rem;
307
  }
308
 
309
  .product-meta {
310
- font-size: 0.8rem;
311
- color: rgba(255, 255, 255, 0.5);
312
- margin-bottom: 0.4rem;
313
  }
314
 
315
  .product-price {
316
- font-size: 1rem;
317
- font-weight: 700;
318
- color: #6C63FF;
319
  }
320
 
321
  .product-rating {
322
- display: flex;
323
- align-items: center;
324
- gap: 0.3rem;
325
- font-size: 0.85rem;
326
- color: #FFD700;
327
  }
328
 
329
  .product-explanation {
330
- font-size: 0.78rem;
331
- color: rgba(255, 255, 255, 0.6);
332
- border-top: 1px solid rgba(255, 255, 255, 0.06);
333
- padding-top: 0.6rem;
334
- margin-top: 0.6rem;
335
- font-style: italic;
336
- }
337
-
338
- .score-badge {
339
- display: inline-block;
340
- background: rgba(108, 99, 255, 0.2);
341
- border-radius: 4px;
342
- padding: 0.1rem 0.4rem;
343
- font-size: 0.7rem;
344
- color: #a99bff;
345
  }
346
 
347
  .compact-row {
348
- display: flex;
349
- gap: 1rem;
350
- align-items: center;
351
- flex-wrap: wrap;
352
  }
353
 
354
- .form-group {
355
- margin-bottom: 1rem;
356
- }
357
-
358
- .form-label {
359
- display: block;
360
- font-size: 0.8rem;
361
- font-weight: 500;
362
- color: rgba(255, 255, 255, 0.7);
363
- margin-bottom: 0.3rem;
364
  }
365
 
 
366
  .tabs {
367
- display: flex;
368
- gap: 0.5rem;
369
- margin-bottom: 1.5rem;
370
- flex-wrap: wrap;
371
  }
372
 
373
  .tab {
374
- padding: 0.6rem 1.2rem;
375
- border-radius: 8px;
376
- background: rgba(255, 255, 255, 0.05);
377
- border: 1px solid rgba(255, 255, 255, 0.08);
378
- color: rgba(255, 255, 255, 0.6);
379
- cursor: pointer;
380
- font-size: 0.85rem;
381
- font-weight: 500;
382
- transition: all 0.2s;
383
- font-family: inherit;
 
 
 
384
  }
385
 
386
  .tab:hover {
387
- background: rgba(255, 255, 255, 0.08);
388
- color: #fff;
389
  }
390
 
391
  .tab.active {
392
- background: rgba(108, 99, 255, 0.2);
393
- border-color: #6C63FF;
394
- color: #fff;
395
  }
396
 
397
  .tab-content {
398
- display: none;
399
  }
400
 
401
  .tab-content.active {
402
- display: block;
403
- animation: fadeIn 0.3s ease;
404
  }
405
 
406
  @keyframes fadeIn {
407
- from { opacity: 0; transform: translateY(10px); }
408
- to { opacity: 1; transform: translateY(0); }
409
  }
410
 
 
411
  .eval-table {
412
- width: 100%;
413
- border-collapse: collapse;
414
- font-size: 0.85rem;
415
  }
416
 
417
  .eval-table th,
418
  .eval-table td {
419
- padding: 0.6rem 0.8rem;
420
- text-align: left;
421
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
422
  }
423
 
424
  .eval-table th {
425
- color: rgba(255, 255, 255, 0.5);
426
- font-weight: 500;
427
- font-size: 0.75rem;
428
- text-transform: uppercase;
429
- letter-spacing: 0.5px;
 
430
  }
431
 
432
  .eval-table td {
433
- color: rgba(255, 255, 255, 0.85);
434
  }
435
 
436
  .eval-table tr:hover td {
437
- background: rgba(255, 255, 255, 0.03);
438
  }
439
 
440
  .best-row {
441
- background: rgba(108, 99, 255, 0.08) !important;
 
 
 
 
 
 
 
442
  }
443
 
 
444
  .loading {
445
- display: flex;
446
- align-items: center;
447
- justify-content: center;
448
- padding: 3rem;
449
- color: rgba(255, 255, 255, 0.5);
 
450
  }
451
 
452
  .spinner {
453
- width: 24px;
454
- height: 24px;
455
- border: 3px solid rgba(255, 255, 255, 0.1);
456
- border-top-color: #6C63FF;
457
- border-radius: 50%;
458
- animation: spin 0.8s linear infinite;
459
- margin-right: 0.75rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
  }
461
 
462
  @keyframes spin {
463
- to { transform: rotate(360deg); }
464
  }
465
 
 
466
  .empty-state {
467
- text-align: center;
468
- padding: 3rem;
469
- color: rgba(255, 255, 255, 0.4);
470
  }
471
 
472
- .empty-state .icon {
473
- font-size: 3rem;
474
- margin-bottom: 1rem;
475
  }
476
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  .chart-container {
478
- position: relative;
479
- height: 300px;
480
- width: 100%;
481
  }
482
 
 
 
 
 
 
 
483
  .analysis-block {
484
- background: rgba(108, 99, 255, 0.06);
485
- border: 1px solid rgba(108, 99, 255, 0.15);
486
- border-radius: 10px;
487
- padding: 1rem;
488
- margin-top: 1rem;
489
  }
490
 
491
  .analysis-block h4 {
492
- color: #a99bff;
493
- margin-bottom: 0.5rem;
494
- font-size: 0.9rem;
495
  }
496
 
497
  .analysis-block p {
498
- font-size: 0.85rem;
499
- color: rgba(255, 255, 255, 0.7);
500
- line-height: 1.5;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  }
502
 
 
503
  .compare-view {
504
- display: grid;
505
- grid-template-columns: 1fr 1fr 1fr;
506
- gap: 1rem;
507
  }
508
 
509
  @media (max-width: 992px) {
510
- .compare-view {
511
- grid-template-columns: 1fr;
512
- }
513
  }
514
 
515
  .compare-column {
516
- background: rgba(255, 255, 255, 0.03);
517
- border-radius: 12px;
518
- padding: 1rem;
519
- border: 1px solid rgba(255, 255, 255, 0.06);
 
 
 
 
 
520
  }
521
 
522
  .compare-column h3 {
523
- font-size: 0.9rem;
524
- color: #fff;
525
- margin-bottom: 1rem;
526
- text-align: center;
527
- padding-bottom: 0.5rem;
528
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  }
530
 
 
531
  .badge-best {
532
- background: linear-gradient(135deg, #6C63FF, #FF6584);
533
- color: #fff;
534
- padding: 0.2rem 0.6rem;
535
- border-radius: 4px;
536
- font-size: 0.7rem;
537
- font-weight: 600;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  }
539
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
  .hidden {
541
- display: none !important;
542
  }
543
 
544
- .mt-1 { margin-top: 0.5rem; }
545
- .mt-2 { margin-top: 1rem; }
546
- .mt-3 { margin-top: 1.5rem; }
547
- .mb-2 { margin-bottom: 1rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --primary: #6C63FF;
3
+ --primary-light: #8B85FF;
4
+ --primary-dark: #4D44E8;
5
+ --primary-rgb: 108, 99, 255;
6
+ --secondary: #FF6584;
7
+ --secondary-light: #FF8DA3;
8
+ --secondary-rgb: 255, 101, 132;
9
+
10
+ --bg-deep: #0f0c29;
11
+ --bg-mid: #302b63;
12
+ --bg-light: #24243e;
13
+ --bg-gradient: linear-gradient(135deg, var(--bg-deep), var(--bg-mid), var(--bg-light));
14
+
15
+ --glass-bg: rgba(255, 255, 255, 0.06);
16
+ --glass-border: rgba(255, 255, 255, 0.1);
17
+ --glass-hover-bg: rgba(255, 255, 255, 0.1);
18
+ --glass-blur: 20px;
19
+ --card-bg: rgba(255, 255, 255, 0.04);
20
+ --card-border: rgba(255, 255, 255, 0.08);
21
+ --card-hover-bg: rgba(255, 255, 255, 0.07);
22
+ --card-hover-border: rgba(var(--primary-rgb), 0.3);
23
+
24
+ --text-primary: #ffffff;
25
+ --text-body: #e0e0e0;
26
+ --text-muted: rgba(255, 255, 255, 0.6);
27
+ --text-dim: rgba(255, 255, 255, 0.4);
28
+
29
+ --star: #FFD700;
30
+ --tag-text: #a99bff;
31
+ --tag-bg: rgba(var(--primary-rgb), 0.2);
32
+ --tag-border: rgba(var(--primary-rgb), 0.3);
33
+ --tag-brand-text: #ff9eb2;
34
+ --tag-brand-bg: rgba(var(--secondary-rgb), 0.15);
35
+ --tag-brand-border: rgba(var(--secondary-rgb), 0.3);
36
+
37
+ --success: #22C55E;
38
+ --error: #EF4444;
39
+ --warning: #F59E0B;
40
+
41
+ --space-xs: 0.25rem;
42
+ --space-sm: 0.5rem;
43
+ --space-md: 1rem;
44
+ --space-lg: 1.5rem;
45
+ --space-xl: 2rem;
46
+
47
+ --radius-sm: 4px;
48
+ --radius-md: 8px;
49
+ --radius-lg: 12px;
50
+ --radius-xl: 16px;
51
+
52
+ --transition-fast: 0.2s ease;
53
+ --transition-normal: 0.3s ease;
54
+ --transition-slow: 0.5s ease;
55
+
56
+ --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
57
+ --shadow-md: 0 8px 25px rgba(0, 0, 0, 0.3);
58
+ --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.3);
59
+ --shadow-glow: 0 8px 25px rgba(var(--primary-rgb), 0.4);
60
+
61
+ --container-max: 1200px;
62
+ }
63
+
64
+ [data-theme="light"] {
65
+ --bg-deep: #f0f2f5;
66
+ --bg-mid: #e2e6ed;
67
+ --bg-light: #ffffff;
68
+ --bg-gradient: linear-gradient(135deg, #f0f2f5, #e2e6ed, #ffffff);
69
+
70
+ --glass-bg: rgba(255, 255, 255, 0.75);
71
+ --glass-border: rgba(0, 0, 0, 0.08);
72
+ --glass-hover-bg: rgba(255, 255, 255, 0.9);
73
+ --card-bg: rgba(255, 255, 255, 0.6);
74
+ --card-border: rgba(0, 0, 0, 0.06);
75
+ --card-hover-bg: rgba(255, 255, 255, 0.85);
76
+ --card-hover-border: rgba(var(--primary-rgb), 0.2);
77
+
78
+ --text-primary: #1a1a2e;
79
+ --text-body: #333333;
80
+ --text-muted: rgba(0, 0, 0, 0.55);
81
+ --text-dim: rgba(0, 0, 0, 0.35);
82
+
83
+ --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
84
+ --shadow-md: 0 8px 25px rgba(0, 0, 0, 0.08);
85
+ --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.1);
86
+ --shadow-glow: 0 8px 25px rgba(var(--primary-rgb), 0.25);
87
+
88
+ --tag-text: var(--primary);
89
+ --tag-brand-text: var(--secondary);
90
+ }
91
+
92
  * {
93
+ margin: 0;
94
+ padding: 0;
95
+ box-sizing: border-box;
96
  }
97
 
98
+ html {
99
+ scroll-behavior: smooth;
 
 
 
100
  }
101
 
102
+ body {
103
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
104
+ background: var(--bg-gradient);
105
+ min-height: 100vh;
106
+ color: var(--text-body);
107
+ transition: background var(--transition-slow), color var(--transition-normal);
108
+ }
109
+
110
+ ::-webkit-scrollbar {
111
+ width: 8px;
112
+ }
113
+ ::-webkit-scrollbar-track {
114
+ background: transparent;
115
+ }
116
+ ::-webkit-scrollbar-thumb {
117
+ background: rgba(var(--primary-rgb), 0.3);
118
+ border-radius: 4px;
119
+ }
120
+ ::-webkit-scrollbar-thumb:hover {
121
+ background: rgba(var(--primary-rgb), 0.5);
122
+ }
123
+
124
+ ::selection {
125
+ background: rgba(var(--primary-rgb), 0.3);
126
+ color: var(--text-primary);
127
+ }
128
+
129
+ /* ===== AMBIENT BACKGROUND ===== */
130
+ .ambient-bg {
131
+ position: fixed;
132
+ top: 0;
133
+ left: 0;
134
+ width: 100%;
135
+ height: 100%;
136
+ z-index: -1;
137
+ overflow: hidden;
138
+ pointer-events: none;
139
+ }
140
+ .ambient-blob {
141
+ position: absolute;
142
+ border-radius: 50%;
143
+ filter: blur(80px);
144
+ opacity: 0.12;
145
+ will-change: transform;
146
+ }
147
+ .ambient-blob-1 {
148
+ width: 600px;
149
+ height: 600px;
150
+ background: var(--primary);
151
+ top: -15%;
152
+ left: -10%;
153
+ animation: floatA 25s ease-in-out infinite;
154
+ }
155
+ .ambient-blob-2 {
156
+ width: 500px;
157
+ height: 500px;
158
+ background: var(--secondary);
159
+ bottom: -15%;
160
+ right: -10%;
161
+ animation: floatB 25s ease-in-out infinite;
162
+ animation-delay: -8s;
163
+ }
164
+ .ambient-blob-3 {
165
+ width: 400px;
166
+ height: 400px;
167
+ background: var(--primary-light);
168
+ top: 50%;
169
+ left: 50%;
170
+ transform: translate(-50%, -50%);
171
+ animation: floatC 30s ease-in-out infinite;
172
+ animation-delay: -16s;
173
+ opacity: 0.06;
174
+ }
175
+
176
+ @keyframes floatA {
177
+ 0%, 100% { transform: translate(0, 0) scale(1); }
178
+ 25% { transform: translate(40px, -40px) scale(1.1); }
179
+ 50% { transform: translate(-20px, 20px) scale(0.95); }
180
+ 75% { transform: translate(30px, 30px) scale(1.05); }
181
+ }
182
+ @keyframes floatB {
183
+ 0%, 100% { transform: translate(0, 0) scale(1); }
184
+ 25% { transform: translate(-30px, 30px) scale(1.08); }
185
+ 50% { transform: translate(20px, -30px) scale(0.92); }
186
+ 75% { transform: translate(-40px, -20px) scale(1.02); }
187
+ }
188
+ @keyframes floatC {
189
+ 0%, 100% { transform: translate(-50%, -50%) scale(1); }
190
+ 33% { transform: translate(-40%, -60%) scale(1.15); }
191
+ 66% { transform: translate(-60%, -40%) scale(0.9); }
192
+ }
193
+
194
+ /* ===== NAVIGATION ===== */
195
  .nav {
196
+ background: rgba(255, 255, 255, 0.05);
197
+ backdrop-filter: blur(20px);
198
+ -webkit-backdrop-filter: blur(20px);
199
+ border-bottom: 1px solid var(--glass-border);
200
+ padding: var(--space-md) var(--space-xl);
201
+ display: flex;
202
+ align-items: center;
203
+ justify-content: space-between;
204
+ position: sticky;
205
+ top: 0;
206
+ z-index: 100;
207
+ transition: background var(--transition-normal);
208
  }
209
 
210
  .nav-brand {
211
+ font-size: 1.3rem;
212
+ font-weight: 700;
213
+ color: var(--text-primary);
214
+ text-decoration: none;
215
+ letter-spacing: -0.5px;
216
+ display: flex;
217
+ align-items: center;
218
+ gap: var(--space-sm);
219
  }
220
 
221
  .nav-brand span {
222
+ color: var(--primary);
223
+ }
224
+
225
+ .nav-brand svg {
226
+ width: 24px;
227
+ height: 24px;
228
+ stroke: var(--primary);
229
+ }
230
+
231
+ .nav-center {
232
+ display: flex;
233
+ gap: var(--space-lg);
234
  }
235
 
236
  .nav-links {
237
+ display: flex;
238
+ gap: var(--space-lg);
239
  }
240
 
241
  .nav-links a {
242
+ color: var(--text-muted);
243
+ text-decoration: none;
244
+ font-size: 0.9rem;
245
+ font-weight: 500;
246
+ transition: color var(--transition-fast);
247
+ padding: var(--space-sm) 0;
248
+ position: relative;
249
  }
250
 
251
+ .nav-links a::after {
252
+ content: '';
253
+ position: absolute;
254
+ bottom: -1px;
255
+ left: 0;
256
+ width: 100%;
257
+ height: 2px;
258
+ background: var(--primary);
259
+ transform: scaleX(0);
260
+ transition: transform var(--transition-fast);
261
+ }
262
+
263
+ .nav-links a:hover {
264
+ color: var(--text-primary);
265
  }
266
 
267
  .nav-links a.active {
268
+ color: var(--text-primary);
269
+ }
270
+
271
+ .nav-links a.active::after {
272
+ transform: scaleX(1);
273
  }
274
 
275
+ .nav-actions {
276
+ display: flex;
277
+ align-items: center;
278
+ gap: var(--space-md);
279
+ }
280
+
281
+ .theme-toggle {
282
+ background: rgba(255, 255, 255, 0.08);
283
+ border: 1px solid var(--glass-border);
284
+ border-radius: 50%;
285
+ width: 36px;
286
+ height: 36px;
287
+ display: flex;
288
+ align-items: center;
289
+ justify-content: center;
290
+ cursor: pointer;
291
+ color: var(--text-muted);
292
+ transition: all var(--transition-fast);
293
+ font-size: 1rem;
294
+ }
295
+ .theme-toggle:hover {
296
+ background: rgba(255, 255, 255, 0.15);
297
+ color: var(--text-primary);
298
+ }
299
+
300
+ /* ===== LAYOUT ===== */
301
  .container {
302
+ max-width: var(--container-max);
303
+ margin: 0 auto;
304
+ padding: var(--space-xl);
305
+ animation: pageIn 0.4s ease;
306
+ }
307
+
308
+ @keyframes pageIn {
309
+ from { opacity: 0; transform: translateY(10px); }
310
+ to { opacity: 1; transform: translateY(0); }
311
  }
312
 
313
+ /* ===== GLASS PANEL ===== */
314
  .glass {
315
+ background: var(--glass-bg);
316
+ backdrop-filter: blur(var(--glass-blur));
317
+ -webkit-backdrop-filter: blur(var(--glass-blur));
318
+ border: 1px solid var(--glass-border);
319
+ border-radius: var(--radius-xl);
320
+ padding: var(--space-lg);
321
+ margin-bottom: var(--space-lg);
322
+ transition: all var(--transition-normal);
323
+ }
324
+
325
+ .glass:hover {
326
+ border-color: rgba(var(--primary-rgb), 0.15);
327
  }
328
 
329
  .glass-header {
330
+ font-size: 1.1rem;
331
+ font-weight: 600;
332
+ color: var(--text-primary);
333
+ margin-bottom: var(--space-md);
334
+ }
335
+
336
+ /* ===== FORM ELEMENTS ===== */
337
+ select, input[type="number"], input[type="text"], input[type="range"] {
338
+ background: rgba(255, 255, 255, 0.08);
339
+ border: 1px solid var(--glass-border);
340
+ border-radius: var(--radius-md);
341
+ padding: 0.65rem 1rem;
342
+ color: var(--text-primary);
343
+ font-size: 0.9rem;
344
+ width: 100%;
345
+ outline: none;
346
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
347
+ appearance: none;
348
+ -webkit-appearance: none;
349
+ font-family: inherit;
350
  }
351
 
352
  select:hover, input:hover {
353
+ border-color: rgba(var(--primary-rgb), 0.5);
354
  }
355
 
356
  select:focus, input:focus {
357
+ border-color: var(--primary);
358
+ box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.15);
359
+ }
360
+
361
+ select {
362
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.5)' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
363
+ background-repeat: no-repeat;
364
+ background-position: right 0.75rem center;
365
+ padding-right: 2.5rem;
366
  }
367
 
368
  select option {
369
+ background: var(--bg-light);
370
+ color: var(--text-primary);
371
+ }
372
+
373
+ input[type="range"] {
374
+ padding: 0;
375
+ border: none;
376
+ background: none;
377
+ height: 6px;
378
+ accent-color: var(--primary);
379
+ }
380
+
381
+ .form-group {
382
+ margin-bottom: var(--space-md);
383
+ }
384
+
385
+ .form-label {
386
+ display: block;
387
+ font-size: 0.8rem;
388
+ font-weight: 500;
389
+ color: var(--text-muted);
390
+ margin-bottom: 0.3rem;
391
  }
392
 
393
+ .form-hint {
394
+ font-size: 0.75rem;
395
+ color: var(--text-dim);
396
+ margin-top: 0.25rem;
397
+ }
398
+
399
+ .form-row {
400
+ display: grid;
401
+ grid-template-columns: 1fr 1fr;
402
+ gap: var(--space-md);
403
+ }
404
+
405
+ /* ===== BUTTONS ===== */
406
  .btn {
407
+ padding: 0.7rem 1.5rem;
408
+ border: none;
409
+ border-radius: var(--radius-md);
410
+ font-size: 0.9rem;
411
+ font-weight: 600;
412
+ cursor: pointer;
413
+ transition: all var(--transition-normal);
414
+ font-family: inherit;
415
+ display: inline-flex;
416
+ align-items: center;
417
+ justify-content: center;
418
+ gap: 0.4rem;
419
+ position: relative;
420
+ overflow: hidden;
421
+ }
422
+
423
+ .btn:active {
424
+ transform: scale(0.97);
425
+ }
426
+
427
+ .btn:disabled {
428
+ opacity: 0.5;
429
+ cursor: not-allowed;
430
+ transform: none !important;
431
  }
432
 
433
  .btn-primary {
434
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
435
+ color: #fff;
436
  }
437
 
438
+ .btn-primary:hover:not(:disabled) {
439
+ transform: translateY(-2px);
440
+ box-shadow: var(--shadow-glow);
441
  }
442
 
443
  .btn-secondary {
444
+ background: rgba(255, 255, 255, 0.1);
445
+ color: var(--text-primary);
446
+ border: 1px solid var(--glass-border);
447
+ }
448
+
449
+ .btn-secondary:hover:not(:disabled) {
450
+ background: rgba(255, 255, 255, 0.15);
451
+ transform: translateY(-1px);
452
+ }
453
+
454
+ .btn-ghost {
455
+ background: transparent;
456
+ color: var(--text-muted);
457
+ border: 1px solid transparent;
458
+ }
459
+
460
+ .btn-ghost:hover:not(:disabled) {
461
+ background: rgba(255, 255, 255, 0.06);
462
+ color: var(--text-primary);
463
+ }
464
+
465
+ .btn-sm {
466
+ padding: 0.4rem 0.8rem;
467
+ font-size: 0.8rem;
468
  }
469
 
470
+ .btn-lg {
471
+ padding: 1rem 2rem;
472
+ font-size: 1rem;
473
  }
474
 
475
  .btn-group {
476
+ display: flex;
477
+ gap: var(--space-sm);
478
+ flex-wrap: wrap;
479
+ }
480
+
481
+ .btn-loading {
482
+ pointer-events: none;
483
+ }
484
+
485
+ .btn-loading .btn-text {
486
+ opacity: 0;
487
+ }
488
+
489
+ .btn-loading::after {
490
+ content: '';
491
+ position: absolute;
492
+ width: 16px;
493
+ height: 16px;
494
+ border: 2px solid rgba(255, 255, 255, 0.3);
495
+ border-top-color: #fff;
496
+ border-radius: 50%;
497
+ animation: spin 0.6s linear infinite;
498
  }
499
 
500
  .btn-approach {
501
+ background: rgba(255, 255, 255, 0.06);
502
+ border: 1px solid rgba(255, 255, 255, 0.1);
503
+ border-radius: var(--radius-md);
504
+ padding: 0.75rem 1.2rem;
505
+ color: var(--text-muted);
506
+ cursor: pointer;
507
+ font-size: 0.85rem;
508
+ font-weight: 500;
509
+ transition: all var(--transition-normal);
510
+ font-family: inherit;
511
+ flex: 1;
512
+ min-width: 120px;
513
+ text-align: center;
514
  }
515
 
516
  .btn-approach:hover {
517
+ background: rgba(var(--primary-rgb), 0.15);
518
+ border-color: var(--primary);
519
+ color: var(--text-primary);
520
+ transform: translateY(-2px);
521
  }
522
 
523
  .btn-approach.active {
524
+ background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.3), rgba(var(--secondary-rgb), 0.2));
525
+ border-color: var(--primary);
526
+ color: var(--text-primary);
527
+ box-shadow: 0 4px 20px rgba(var(--primary-rgb), 0.2);
528
  }
529
 
530
  .btn-approach .icon {
531
+ font-size: 1.5rem;
532
+ display: block;
533
+ margin-bottom: 0.3rem;
534
  }
535
 
536
  .btn-method {
537
+ background: rgba(255, 255, 255, 0.04);
538
+ border: 1px solid rgba(255, 255, 255, 0.08);
539
+ border-radius: 6px;
540
+ padding: 0.4rem 0.8rem;
541
+ color: var(--text-muted);
542
+ cursor: pointer;
543
+ font-size: 0.8rem;
544
+ transition: all var(--transition-fast);
545
+ font-family: inherit;
546
  }
547
 
548
  .btn-method:hover {
549
+ background: rgba(255, 255, 255, 0.08);
550
+ color: var(--text-primary);
551
  }
552
 
553
  .btn-method.active {
554
+ background: rgba(var(--primary-rgb), 0.2);
555
+ border-color: var(--primary);
556
+ color: var(--text-primary);
557
  }
558
 
559
+ /* ===== PROFILE CARD ===== */
560
  .profile-card {
561
+ display: flex;
562
+ gap: var(--space-xl);
563
+ align-items: center;
564
+ flex-wrap: wrap;
565
  }
566
 
567
  .profile-avatar {
568
+ width: 64px;
569
+ height: 64px;
570
+ border-radius: 50%;
571
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
572
+ display: flex;
573
+ align-items: center;
574
+ justify-content: center;
575
+ font-size: 1.5rem;
576
+ font-weight: 700;
577
+ color: #fff;
578
+ flex-shrink: 0;
579
+ position: relative;
580
+ }
581
+
582
+ .profile-avatar::after {
583
+ content: '';
584
+ position: absolute;
585
+ inset: -3px;
586
+ border-radius: 50%;
587
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
588
+ opacity: 0.3;
589
+ z-index: -1;
590
+ animation: pulse-ring 2s ease-in-out infinite;
591
+ }
592
+
593
+ @keyframes pulse-ring {
594
+ 0%, 100% { transform: scale(1); opacity: 0.3; }
595
+ 50% { transform: scale(1.08); opacity: 0.1; }
596
  }
597
 
598
  .profile-info {
599
+ flex: 1;
600
  }
601
 
602
  .profile-name {
603
+ font-size: 1.1rem;
604
+ font-weight: 600;
605
+ color: var(--text-primary);
606
  }
607
 
608
  .profile-detail {
609
+ font-size: 0.85rem;
610
+ color: var(--text-muted);
611
+ margin-top: 0.2rem;
612
  }
613
 
614
  .profile-tags {
615
+ display: flex;
616
+ gap: 0.4rem;
617
+ flex-wrap: wrap;
618
  }
619
 
620
+ /* ===== TAGS ===== */
621
  .tag {
622
+ background: var(--tag-bg);
623
+ border: 1px solid var(--tag-border);
624
+ border-radius: var(--radius-sm);
625
+ padding: 0.15rem 0.5rem;
626
+ font-size: 0.75rem;
627
+ color: var(--tag-text);
628
+ }
629
+
630
+ .tag-brand {
631
+ background: var(--tag-brand-bg);
632
+ border-color: var(--tag-brand-border);
633
+ color: var(--tag-brand-text);
634
  }
635
 
636
+ /* ===== GRID ===== */
637
  .row {
638
+ display: grid;
639
+ grid-template-columns: 1fr 1fr;
640
+ gap: var(--space-lg);
641
  }
642
 
643
  @media (max-width: 768px) {
644
+ .row {
645
+ grid-template-columns: 1fr;
646
+ }
647
  }
648
 
649
+ /* ===== PRODUCT CARD ===== */
650
  .product-grid {
651
+ display: grid;
652
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
653
+ gap: var(--space-md);
654
  }
655
 
656
  .product-card {
657
+ background: var(--card-bg);
658
+ border: 1px solid var(--card-border);
659
+ border-radius: var(--radius-lg);
660
+ padding: var(--space-md);
661
+ transition: all var(--transition-normal);
662
+ cursor: default;
663
+ position: relative;
664
+ overflow: hidden;
665
+ }
666
+
667
+ .product-card::before {
668
+ content: '';
669
+ position: absolute;
670
+ top: 0;
671
+ left: 0;
672
+ right: 0;
673
+ height: 3px;
674
+ background: linear-gradient(90deg, var(--primary), var(--secondary));
675
+ transform: scaleX(0);
676
+ transform-origin: left;
677
+ transition: transform var(--transition-normal);
678
  }
679
 
680
  .product-card:hover {
681
+ transform: translateY(-4px);
682
+ background: var(--card-hover-bg);
683
+ border-color: var(--card-hover-border);
684
+ box-shadow: var(--shadow-lg);
685
+ }
686
+
687
+ .product-card:hover::before {
688
+ transform: scaleX(1);
689
+ }
690
+
691
+ .product-card .score-badge-top {
692
+ position: absolute;
693
+ top: var(--space-sm);
694
+ right: var(--space-sm);
695
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
696
+ color: #fff;
697
+ border-radius: var(--radius-sm);
698
+ padding: 0.15rem 0.5rem;
699
+ font-size: 0.7rem;
700
+ font-weight: 600;
701
  }
702
 
703
  .product-icon {
704
+ width: 48px;
705
+ height: 48px;
706
+ border-radius: 10px;
707
+ background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.2), rgba(var(--secondary-rgb), 0.1));
708
+ display: flex;
709
+ align-items: center;
710
+ justify-content: center;
711
+ font-size: 1.5rem;
712
+ margin-bottom: 0.75rem;
713
  }
714
 
715
  .product-name {
716
+ font-size: 0.95rem;
717
+ font-weight: 600;
718
+ color: var(--text-primary);
719
+ margin-bottom: 0.3rem;
720
  }
721
 
722
  .product-meta {
723
+ font-size: 0.8rem;
724
+ color: var(--text-muted);
725
+ margin-bottom: 0.4rem;
726
  }
727
 
728
  .product-price {
729
+ font-size: 1rem;
730
+ font-weight: 700;
731
+ color: var(--primary);
732
  }
733
 
734
  .product-rating {
735
+ display: flex;
736
+ align-items: center;
737
+ gap: 0.3rem;
738
+ font-size: 0.85rem;
739
+ color: var(--star);
740
  }
741
 
742
  .product-explanation {
743
+ font-size: 0.78rem;
744
+ color: var(--text-muted);
745
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
746
+ padding-top: 0.6rem;
747
+ margin-top: 0.6rem;
748
+ font-style: italic;
749
+ line-height: 1.4;
 
 
 
 
 
 
 
 
750
  }
751
 
752
  .compact-row {
753
+ display: flex;
754
+ gap: var(--space-md);
755
+ align-items: center;
756
+ flex-wrap: wrap;
757
  }
758
 
759
+ /* ===== SCORE BADGE ===== */
760
+ .score-badge {
761
+ display: inline-block;
762
+ background: var(--tag-bg);
763
+ border-radius: var(--radius-sm);
764
+ padding: 0.1rem 0.4rem;
765
+ font-size: 0.7rem;
766
+ color: var(--tag-text);
 
 
767
  }
768
 
769
+ /* ===== TABS ===== */
770
  .tabs {
771
+ display: flex;
772
+ gap: var(--space-sm);
773
+ margin-bottom: var(--space-lg);
774
+ flex-wrap: wrap;
775
  }
776
 
777
  .tab {
778
+ padding: 0.6rem 1.2rem;
779
+ border-radius: var(--radius-md);
780
+ background: rgba(255, 255, 255, 0.05);
781
+ border: 1px solid rgba(255, 255, 255, 0.08);
782
+ color: var(--text-muted);
783
+ cursor: pointer;
784
+ font-size: 0.85rem;
785
+ font-weight: 500;
786
+ transition: all var(--transition-fast);
787
+ font-family: inherit;
788
+ display: flex;
789
+ align-items: center;
790
+ gap: 0.3rem;
791
  }
792
 
793
  .tab:hover {
794
+ background: rgba(255, 255, 255, 0.08);
795
+ color: var(--text-primary);
796
  }
797
 
798
  .tab.active {
799
+ background: rgba(var(--primary-rgb), 0.2);
800
+ border-color: var(--primary);
801
+ color: var(--text-primary);
802
  }
803
 
804
  .tab-content {
805
+ display: none;
806
  }
807
 
808
  .tab-content.active {
809
+ display: block;
810
+ animation: fadeIn 0.3s ease;
811
  }
812
 
813
  @keyframes fadeIn {
814
+ from { opacity: 0; transform: translateY(10px); }
815
+ to { opacity: 1; transform: translateY(0); }
816
  }
817
 
818
+ /* ===== EVALUATION TABLE ===== */
819
  .eval-table {
820
+ width: 100%;
821
+ border-collapse: collapse;
822
+ font-size: 0.85rem;
823
  }
824
 
825
  .eval-table th,
826
  .eval-table td {
827
+ padding: 0.6rem 0.8rem;
828
+ text-align: left;
829
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
830
  }
831
 
832
  .eval-table th {
833
+ color: var(--text-muted);
834
+ font-weight: 500;
835
+ font-size: 0.75rem;
836
+ text-transform: uppercase;
837
+ letter-spacing: 0.5px;
838
+ white-space: nowrap;
839
  }
840
 
841
  .eval-table td {
842
+ color: var(--text-body);
843
  }
844
 
845
  .eval-table tr:hover td {
846
+ background: rgba(255, 255, 255, 0.03);
847
  }
848
 
849
  .best-row {
850
+ background: rgba(var(--primary-rgb), 0.08) !important;
851
+ }
852
+
853
+ .eval-table .metric-improved {
854
+ color: var(--success);
855
+ }
856
+ .eval-table .metric-declined {
857
+ color: var(--error);
858
  }
859
 
860
+ /* ===== LOADING STATES ===== */
861
  .loading {
862
+ display: flex;
863
+ align-items: center;
864
+ justify-content: center;
865
+ padding: 3rem;
866
+ color: var(--text-muted);
867
+ gap: 0.75rem;
868
  }
869
 
870
  .spinner {
871
+ width: 24px;
872
+ height: 24px;
873
+ border: 3px solid rgba(255, 255, 255, 0.1);
874
+ border-top-color: var(--primary);
875
+ border-radius: 50%;
876
+ animation: spin 0.8s linear infinite;
877
+ }
878
+
879
+ .skeleton {
880
+ background: linear-gradient(
881
+ 90deg,
882
+ rgba(255, 255, 255, 0.04) 25%,
883
+ rgba(255, 255, 255, 0.08) 50%,
884
+ rgba(255, 255, 255, 0.04) 75%
885
+ );
886
+ background-size: 200% 100%;
887
+ animation: shimmer 1.5s ease-in-out infinite;
888
+ border-radius: var(--radius-md);
889
+ }
890
+
891
+ @keyframes shimmer {
892
+ 0% { background-position: 200% 0; }
893
+ 100% { background-position: -200% 0; }
894
+ }
895
+
896
+ .skeleton-card {
897
+ height: 180px;
898
+ padding: var(--space-md);
899
+ }
900
+
901
+ .skeleton-line {
902
+ height: 12px;
903
+ margin-bottom: 8px;
904
+ width: 80%;
905
+ }
906
+ .skeleton-line:last-child {
907
+ width: 60%;
908
  }
909
 
910
  @keyframes spin {
911
+ to { transform: rotate(360deg); }
912
  }
913
 
914
+ /* ===== EMPTY STATE ===== */
915
  .empty-state {
916
+ text-align: center;
917
+ padding: 3rem;
918
+ color: var(--text-dim);
919
  }
920
 
921
+ .empty-state .empty-icon {
922
+ font-size: 3rem;
923
+ margin-bottom: var(--space-md);
924
  }
925
 
926
+ .empty-state .empty-title {
927
+ font-size: 1.1rem;
928
+ font-weight: 600;
929
+ color: var(--text-muted);
930
+ margin-bottom: var(--space-sm);
931
+ }
932
+
933
+ .empty-state .empty-desc {
934
+ font-size: 0.85rem;
935
+ color: var(--text-dim);
936
+ }
937
+
938
+ /* ===== CHART ===== */
939
  .chart-container {
940
+ position: relative;
941
+ height: 300px;
942
+ width: 100%;
943
  }
944
 
945
+ .chart-container canvas {
946
+ max-height: 100%;
947
+ max-width: 100%;
948
+ }
949
+
950
+ /* ===== ANALYSIS BLOCK ===== */
951
  .analysis-block {
952
+ background: rgba(var(--primary-rgb), 0.06);
953
+ border: 1px solid rgba(var(--primary-rgb), 0.15);
954
+ border-radius: 10px;
955
+ padding: 1rem;
956
+ margin-top: var(--space-md);
957
  }
958
 
959
  .analysis-block h4 {
960
+ color: var(--tag-text);
961
+ margin-bottom: var(--space-sm);
962
+ font-size: 0.9rem;
963
  }
964
 
965
  .analysis-block p {
966
+ font-size: 0.85rem;
967
+ color: var(--text-muted);
968
+ line-height: 1.6;
969
+ }
970
+
971
+ .analysis-block ul {
972
+ list-style: none;
973
+ padding: 0;
974
+ }
975
+ .analysis-block li {
976
+ font-size: 0.85rem;
977
+ color: var(--text-muted);
978
+ padding: 0.25rem 0;
979
+ line-height: 1.5;
980
+ }
981
+ .analysis-block li::before {
982
+ content: '▸ ';
983
+ color: var(--primary);
984
  }
985
 
986
+ /* ===== COMPARE VIEW ===== */
987
  .compare-view {
988
+ display: grid;
989
+ grid-template-columns: 1fr 1fr 1fr;
990
+ gap: var(--space-md);
991
  }
992
 
993
  @media (max-width: 992px) {
994
+ .compare-view {
995
+ grid-template-columns: 1fr;
996
+ }
997
  }
998
 
999
  .compare-column {
1000
+ background: rgba(255, 255, 255, 0.03);
1001
+ border-radius: var(--radius-lg);
1002
+ padding: var(--space-md);
1003
+ border: 1px solid var(--card-border);
1004
+ transition: all var(--transition-normal);
1005
+ }
1006
+
1007
+ .compare-column:hover {
1008
+ border-color: rgba(var(--primary-rgb), 0.2);
1009
  }
1010
 
1011
  .compare-column h3 {
1012
+ font-size: 0.9rem;
1013
+ color: var(--text-primary);
1014
+ margin-bottom: var(--space-md);
1015
+ text-align: center;
1016
+ padding-bottom: var(--space-sm);
1017
+ border-bottom: 1px solid var(--card-border);
1018
+ }
1019
+
1020
+ .compare-item {
1021
+ padding: 0.5rem 0;
1022
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
1023
+ font-size: 0.8rem;
1024
+ }
1025
+ .compare-item:last-child {
1026
+ border-bottom: none;
1027
+ }
1028
+ .compare-item-name {
1029
+ font-weight: 500;
1030
+ color: var(--text-primary);
1031
+ }
1032
+ .compare-item-meta {
1033
+ color: var(--text-dim);
1034
+ margin-top: 0.15rem;
1035
  }
1036
 
1037
+ /* ===== BADGE ===== */
1038
  .badge-best {
1039
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
1040
+ color: #fff;
1041
+ padding: 0.2rem 0.6rem;
1042
+ border-radius: var(--radius-sm);
1043
+ font-size: 0.7rem;
1044
+ font-weight: 600;
1045
+ display: inline-block;
1046
+ }
1047
+
1048
+ /* ===== TOAST NOTIFICATIONS ===== */
1049
+ .toast-container {
1050
+ position: fixed;
1051
+ bottom: var(--space-xl);
1052
+ right: var(--space-xl);
1053
+ z-index: 9999;
1054
+ display: flex;
1055
+ flex-direction: column;
1056
+ gap: 0.5rem;
1057
+ pointer-events: none;
1058
+ }
1059
+
1060
+ .toast {
1061
+ pointer-events: auto;
1062
+ background: var(--glass-bg);
1063
+ backdrop-filter: blur(20px);
1064
+ -webkit-backdrop-filter: blur(20px);
1065
+ border: 1px solid var(--glass-border);
1066
+ border-radius: var(--radius-md);
1067
+ padding: 0.75rem 1rem;
1068
+ color: var(--text-primary);
1069
+ font-size: 0.85rem;
1070
+ display: flex;
1071
+ align-items: center;
1072
+ gap: 0.5rem;
1073
+ box-shadow: var(--shadow-md);
1074
+ min-width: 280px;
1075
+ max-width: 400px;
1076
+ animation: toastIn 0.35s ease, toastOut 0.35s ease 3s forwards;
1077
+ }
1078
+
1079
+ .toast-success {
1080
+ border-left: 3px solid var(--success);
1081
+ }
1082
+ .toast-error {
1083
+ border-left: 3px solid var(--error);
1084
+ }
1085
+ .toast-info {
1086
+ border-left: 3px solid var(--primary);
1087
+ }
1088
+ .toast-warning {
1089
+ border-left: 3px solid var(--warning);
1090
+ }
1091
+
1092
+ .toast-icon {
1093
+ flex-shrink: 0;
1094
+ width: 20px;
1095
+ height: 20px;
1096
+ display: flex;
1097
+ align-items: center;
1098
+ justify-content: center;
1099
+ }
1100
+ .toast-success .toast-icon { color: var(--success); }
1101
+ .toast-error .toast-icon { color: var(--error); }
1102
+ .toast-info .toast-icon { color: var(--primary); }
1103
+ .toast-warning .toast-icon { color: var(--warning); }
1104
+
1105
+ .toast-close {
1106
+ margin-left: auto;
1107
+ background: none;
1108
+ border: none;
1109
+ color: var(--text-dim);
1110
+ cursor: pointer;
1111
+ padding: 0.15rem;
1112
+ font-size: 1rem;
1113
+ line-height: 1;
1114
+ }
1115
+
1116
+ @keyframes toastIn {
1117
+ from { transform: translateX(100%); opacity: 0; }
1118
+ to { transform: translateX(0); opacity: 1; }
1119
+ }
1120
+ @keyframes toastOut {
1121
+ from { opacity: 1; }
1122
+ to { opacity: 0; transform: translateY(-10px); }
1123
+ }
1124
+
1125
+ /* ===== PREFERENCE EDITOR ===== */
1126
+ .preference-slider {
1127
+ display: flex;
1128
+ align-items: center;
1129
+ gap: var(--space-md);
1130
+ padding: 0.5rem 0;
1131
+ }
1132
+ .preference-slider label {
1133
+ min-width: 120px;
1134
+ font-size: 0.85rem;
1135
+ color: var(--text-muted);
1136
+ }
1137
+ .preference-slider input[type="range"] {
1138
+ flex: 1;
1139
+ }
1140
+ .preference-slider .slider-value {
1141
+ min-width: 40px;
1142
+ text-align: right;
1143
+ font-size: 0.85rem;
1144
+ font-weight: 600;
1145
+ color: var(--primary);
1146
+ }
1147
+
1148
+ /* ===== FILTER CONTROLS ===== */
1149
+ .filter-bar {
1150
+ display: flex;
1151
+ gap: var(--space-md);
1152
+ flex-wrap: wrap;
1153
+ align-items: end;
1154
+ margin-bottom: var(--space-md);
1155
+ }
1156
+
1157
+ .filter-bar .form-group {
1158
+ margin-bottom: 0;
1159
+ min-width: 160px;
1160
+ }
1161
+
1162
+ .filter-bar .form-group select,
1163
+ .filter-bar .form-group input {
1164
+ width: auto;
1165
+ min-width: 140px;
1166
+ }
1167
+
1168
+ /* ===== RESPONSIVE ===== */
1169
+ @media (max-width: 768px) {
1170
+ .nav {
1171
+ padding: var(--space-md);
1172
+ flex-wrap: wrap;
1173
+ gap: var(--space-sm);
1174
+ }
1175
+ .nav-links {
1176
+ gap: var(--space-sm);
1177
+ font-size: 0.8rem;
1178
+ }
1179
+ .container {
1180
+ padding: var(--space-md);
1181
+ }
1182
+ .glass {
1183
+ padding: var(--space-md);
1184
+ }
1185
+ .form-row {
1186
+ grid-template-columns: 1fr;
1187
+ }
1188
+ .product-grid {
1189
+ grid-template-columns: 1fr;
1190
+ }
1191
+ .toast-container {
1192
+ bottom: var(--space-md);
1193
+ right: var(--space-md);
1194
+ left: var(--space-md);
1195
+ }
1196
+ .toast {
1197
+ min-width: 0;
1198
+ max-width: 100%;
1199
+ }
1200
+ .eval-table {
1201
+ font-size: 0.75rem;
1202
+ }
1203
+ .eval-table th,
1204
+ .eval-table td {
1205
+ padding: 0.4rem 0.5rem;
1206
+ }
1207
+ .nav-center {
1208
+ order: 3;
1209
+ width: 100%;
1210
+ justify-content: center;
1211
+ }
1212
  }
1213
 
1214
+ @media (max-width: 992px) {
1215
+ .profile-card {
1216
+ flex-direction: column;
1217
+ text-align: center;
1218
+ }
1219
+ .profile-tags {
1220
+ justify-content: center;
1221
+ }
1222
+ }
1223
+
1224
+ @media (prefers-reduced-motion: reduce) {
1225
+ *, *::before, *::after {
1226
+ animation-duration: 0.01ms !important;
1227
+ animation-iteration-count: 1 !important;
1228
+ transition-duration: 0.01ms !important;
1229
+ }
1230
+ .ambient-blob { display: none; }
1231
+ html { scroll-behavior: auto; }
1232
+ }
1233
+
1234
+ /* ===== CATEGORY ACCENT COLORS ===== */
1235
+ :root {
1236
+ --cat-electronics: #3B82F6;
1237
+ --cat-clothing: #EC4899;
1238
+ --cat-home-kitchen: #F59E0B;
1239
+ --cat-books: #14B8A6;
1240
+ --cat-sports: #22C55E;
1241
+ --cat-beauty: #A855F7;
1242
+ --cat-toys: #F97316;
1243
+ --cat-automotive: #EF4444;
1244
+ }
1245
+
1246
+ .product-card[data-category="Electronics"]::before { background: var(--cat-electronics); }
1247
+ .product-card[data-category="Clothing"]::before { background: var(--cat-clothing); }
1248
+ .product-card[data-category="Home & Kitchen"]::before { background: var(--cat-home-kitchen); }
1249
+ .product-card[data-category="Books"]::before { background: var(--cat-books); }
1250
+ .product-card[data-category="Sports"]::before { background: var(--cat-sports); }
1251
+ .product-card[data-category="Beauty"]::before { background: var(--cat-beauty); }
1252
+ .product-card[data-category="Toys"]::before { background: var(--cat-toys); }
1253
+ .product-card[data-category="Automotive"]::before { background: var(--cat-automotive); }
1254
+
1255
+ .product-card[data-category="Electronics"]:hover { border-color: rgba(59, 130, 246, 0.3); }
1256
+ .product-card[data-category="Clothing"]:hover { border-color: rgba(236, 72, 153, 0.3); }
1257
+ .product-card[data-category="Home & Kitchen"]:hover { border-color: rgba(245, 158, 11, 0.3); }
1258
+ .product-card[data-category="Books"]:hover { border-color: rgba(20, 184, 166, 0.3); }
1259
+ .product-card[data-category="Sports"]:hover { border-color: rgba(34, 197, 94, 0.3); }
1260
+ .product-card[data-category="Beauty"]:hover { border-color: rgba(168, 85, 247, 0.3); }
1261
+ .product-card[data-category="Toys"]:hover { border-color: rgba(249, 115, 22, 0.3); }
1262
+ .product-card[data-category="Automotive"]:hover { border-color: rgba(239, 68, 68, 0.3); }
1263
+
1264
+ /* ===== RELEVANCE PROGRESS BAR ===== */
1265
+ .product-score-bar {
1266
+ height: 3px;
1267
+ border-radius: 2px;
1268
+ margin-top: 0.5rem;
1269
+ background: rgba(255, 255, 255, 0.06);
1270
+ overflow: hidden;
1271
+ }
1272
+ .product-score-bar-fill {
1273
+ height: 100%;
1274
+ border-radius: 2px;
1275
+ background: linear-gradient(90deg, var(--primary), var(--secondary));
1276
+ transition: width 0.6s ease;
1277
+ }
1278
+
1279
+ /* ===== STAGGERED CARD ENTRY ===== */
1280
+ .product-card.stagger {
1281
+ animation: cardIn 0.4s ease backwards;
1282
+ }
1283
+ @keyframes cardIn {
1284
+ from { opacity: 0; transform: translateY(15px) scale(0.97); }
1285
+ to { opacity: 1; transform: translateY(0) scale(1); }
1286
+ }
1287
+
1288
+ /* ===== TOOLTIP ===== */
1289
+ .tooltip-trigger {
1290
+ position: relative;
1291
+ cursor: help;
1292
+ border-bottom: 1px dashed var(--text-dim);
1293
+ }
1294
+ .tooltip-trigger .tooltip-content {
1295
+ position: absolute;
1296
+ bottom: calc(100% + 8px);
1297
+ left: 50%;
1298
+ transform: translateX(-50%) translateY(4px);
1299
+ background: var(--bg-light);
1300
+ border: 1px solid var(--glass-border);
1301
+ border-radius: var(--radius-md);
1302
+ padding: 0.5rem 0.75rem;
1303
+ font-size: 0.75rem;
1304
+ color: var(--text-body);
1305
+ white-space: nowrap;
1306
+ opacity: 0;
1307
+ pointer-events: none;
1308
+ transition: all var(--transition-fast);
1309
+ z-index: 50;
1310
+ box-shadow: var(--shadow-md);
1311
+ line-height: 1.4;
1312
+ font-style: normal;
1313
+ font-weight: 400;
1314
+ }
1315
+ .tooltip-trigger:hover .tooltip-content {
1316
+ opacity: 1;
1317
+ transform: translateX(-50%) translateY(0);
1318
+ }
1319
+ .tooltip-trigger .tooltip-content::after {
1320
+ content: '';
1321
+ position: absolute;
1322
+ top: 100%;
1323
+ left: 50%;
1324
+ transform: translateX(-50%);
1325
+ border: 5px solid transparent;
1326
+ border-top-color: var(--glass-border);
1327
+ }
1328
+
1329
+ /* ===== BREADCRUMB ===== */
1330
+ .breadcrumb {
1331
+ display: flex;
1332
+ align-items: center;
1333
+ gap: 0.4rem;
1334
+ font-size: 0.75rem;
1335
+ color: var(--text-dim);
1336
+ margin-bottom: var(--space-md);
1337
+ padding: 0;
1338
+ list-style: none;
1339
+ }
1340
+ .breadcrumb li { display: flex; align-items: center; gap: 0.4rem; }
1341
+ .breadcrumb a {
1342
+ color: var(--text-muted);
1343
+ text-decoration: none;
1344
+ transition: color var(--transition-fast);
1345
+ }
1346
+ .breadcrumb a:hover { color: var(--primary); }
1347
+ .breadcrumb .sep { color: var(--text-dim); }
1348
+ .breadcrumb .current { color: var(--text-primary); font-weight: 500; }
1349
+
1350
+ /* ===== KPI METRIC CARDS ===== */
1351
+ .kpi-grid {
1352
+ display: grid;
1353
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1354
+ gap: var(--space-md);
1355
+ margin-bottom: var(--space-lg);
1356
+ }
1357
+ .kpi-card {
1358
+ background: var(--card-bg);
1359
+ border: 1px solid var(--card-border);
1360
+ border-radius: var(--radius-lg);
1361
+ padding: var(--space-md);
1362
+ text-align: center;
1363
+ transition: all var(--transition-normal);
1364
+ }
1365
+ .kpi-card:hover {
1366
+ border-color: var(--card-hover-border);
1367
+ transform: translateY(-2px);
1368
+ }
1369
+ .kpi-card .kpi-label {
1370
+ font-size: 0.7rem;
1371
+ text-transform: uppercase;
1372
+ letter-spacing: 0.5px;
1373
+ color: var(--text-dim);
1374
+ margin-bottom: 0.3rem;
1375
+ }
1376
+ .kpi-card .kpi-value {
1377
+ font-size: 1.5rem;
1378
+ font-weight: 700;
1379
+ color: var(--text-primary);
1380
+ }
1381
+ .kpi-card .kpi-unit {
1382
+ font-size: 0.75rem;
1383
+ color: var(--text-muted);
1384
+ margin-top: 0.15rem;
1385
+ }
1386
+ .kpi-card.kpi-primary .kpi-value { color: var(--primary); }
1387
+ .kpi-card.kpi-secondary .kpi-value { color: var(--secondary); }
1388
+ .kpi-card.kpi-success .kpi-value { color: var(--success); }
1389
+ .kpi-card.kpi-warning .kpi-value { color: var(--warning); }
1390
+
1391
+ /* ===== COMPARE VIEW SYNCED SCROLL ===== */
1392
+ .compare-view.synced-scroll .compare-column {
1393
+ overflow-y: auto;
1394
+ max-height: 500px;
1395
+ }
1396
+
1397
+ /* ===== KEYBOARD SHORTCUT HINT ===== */
1398
+ .kbd-hint {
1399
+ position: fixed;
1400
+ bottom: var(--space-md);
1401
+ left: 50%;
1402
+ transform: translateX(-50%);
1403
+ background: var(--glass-bg);
1404
+ backdrop-filter: blur(12px);
1405
+ border: 1px solid var(--glass-border);
1406
+ border-radius: var(--radius-md);
1407
+ padding: 0.4rem 1rem;
1408
+ font-size: 0.7rem;
1409
+ color: var(--text-dim);
1410
+ z-index: 50;
1411
+ white-space: nowrap;
1412
+ opacity: 0;
1413
+ transition: opacity var(--transition-slow);
1414
+ pointer-events: none;
1415
+ }
1416
+ .kbd-hint.visible { opacity: 1; }
1417
+ .kbd-hint kbd {
1418
+ display: inline-block;
1419
+ background: rgba(255,255,255,0.1);
1420
+ border-radius: 3px;
1421
+ padding: 0 0.3rem;
1422
+ font-family: inherit;
1423
+ font-size: 0.65rem;
1424
+ border: 1px solid var(--glass-border);
1425
+ margin: 0 0.1rem;
1426
+ }
1427
+
1428
+ [x-cloak] { display: none !important; }
1429
+
1430
+ /* ===== UTILITIES ===== */
1431
  .hidden {
1432
+ display: none !important;
1433
  }
1434
 
1435
+ .mt-1 { margin-top: var(--space-sm); }
1436
+ .mt-2 { margin-top: var(--space-md); }
1437
+ .mt-3 { margin-top: var(--space-lg); }
1438
+ .mb-1 { margin-bottom: var(--space-sm); }
1439
+ .mb-2 { margin-bottom: var(--space-md); }
1440
+ .mb-3 { margin-bottom: var(--space-lg); }
1441
+
1442
+ .text-center { text-align: center; }
1443
+ .text-muted { color: var(--text-muted); }
1444
+ .text-dim { color: var(--text-dim); }
1445
+
1446
+ .flex { display: flex; }
1447
+ .flex-center { display: flex; align-items: center; justify-content: center; }
1448
+ .flex-between { display: flex; align-items: center; justify-content: space-between; }
1449
+ .gap-sm { gap: var(--space-sm); }
1450
+ .gap-md { gap: var(--space-md); }
1451
+
1452
+ .w-full { width: 100%; }
templates/base.html ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" x-data="{ theme: localStorage.getItem('theme') || 'dark' }" :data-theme="theme">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% block title %}TasteEngine{% endblock %}</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
9
+ <link rel="stylesheet" href="/static/css/style.css">
10
+ <script src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js" defer></script>
11
+ <script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js" defer></script>
12
+ <script src="https://unpkg.com/lucide@0.468.0/dist/umd/lucide.js" defer></script>
13
+ {% block head_extra %}{% endblock %}
14
+ </head>
15
+ <body data-page="{{ active_page|default('home') }}">
16
+ <div class="ambient-bg">
17
+ <div class="ambient-blob ambient-blob-1"></div>
18
+ <div class="ambient-blob ambient-blob-2"></div>
19
+ <div class="ambient-blob ambient-blob-3"></div>
20
+ </div>
21
+
22
+ {% from "macros.html" import nav %}
23
+ {{ nav(active_page|default('home')) }}
24
+
25
+ <div class="container">
26
+ {% block breadcrumb %}{% endblock %}
27
+ {% block content %}{% endblock %}
28
+ </div>
29
+
30
+ <div id="toastContainer" class="toast-container"></div>
31
+
32
+ <script>
33
+ function toggleTheme() {
34
+ const html = document.documentElement;
35
+ const current = html.getAttribute('data-theme') || 'dark';
36
+ const next = current === 'dark' ? 'light' : 'dark';
37
+ html.setAttribute('data-theme', next);
38
+ localStorage.setItem('theme', next);
39
+ document.getElementById('themeIcon').textContent = next === 'dark' ? '🌙' : '☀️';
40
+ }
41
+
42
+ function toast(message, type = 'info', duration = 3000) {
43
+ const container = document.getElementById('toastContainer');
44
+ const icons = { success: '✓', error: '✕', info: 'ℹ', warning: '⚠' };
45
+ const t = document.createElement('div');
46
+ t.className = `toast toast-${type}`;
47
+ t.innerHTML = `
48
+ <span class="toast-icon">${icons[type] || 'ℹ'}</span>
49
+ <span>${message}</span>
50
+ <button class="toast-close" onclick="this.parentElement.remove()">×</button>
51
+ `;
52
+ container.appendChild(t);
53
+ setTimeout(() => { if (t.parentElement) t.remove(); }, duration);
54
+ }
55
+
56
+ function copyToClipboard(text) {
57
+ navigator.clipboard.writeText(text).then(() => toast('Copied!', 'success'));
58
+ }
59
+
60
+ document.addEventListener('DOMContentLoaded', function() {
61
+ const theme = localStorage.getItem('theme') || 'dark';
62
+ document.documentElement.setAttribute('data-theme', theme);
63
+ document.getElementById('themeIcon').textContent = theme === 'dark' ? '🌙' : '☀️';
64
+ });
65
+
66
+ document.addEventListener('htmx:beforeRequest', function() {
67
+ document.querySelectorAll('.btn.htmx-request').forEach(function(btn) {
68
+ btn.disabled = true;
69
+ });
70
+ });
71
+ document.addEventListener('htmx:afterRequest', function() {
72
+ document.querySelectorAll('.btn.htmx-request').forEach(function(btn) {
73
+ btn.disabled = false;
74
+ });
75
+ });
76
+ document.addEventListener('htmx:responseError', function(evt) {
77
+ toast('Request failed: ' + (evt.detail.xhr.statusText || 'Unknown error'), 'error');
78
+ });
79
+
80
+ function getCategoryIcon(category) {
81
+ const icons = {
82
+ 'Electronics': '💻',
83
+ 'Clothing': '👕',
84
+ 'Home & Kitchen': '🏠',
85
+ 'Books': '📚',
86
+ 'Sports': '⚽',
87
+ 'Beauty': '💄',
88
+ 'Toys': '🧸',
89
+ 'Automotive': '🚗'
90
+ };
91
+ return icons[category] || '📦';
92
+ }
93
+
94
+ function stars(rating) {
95
+ const f = Math.floor(rating);
96
+ return '★'.repeat(f) + '☆'.repeat(5 - f);
97
+ }
98
+
99
+ function syncScroll(source) {
100
+ const container = source.closest('.synced-scroll');
101
+ if (!container) return;
102
+ const pct = source.scrollTop / (source.scrollHeight - source.clientHeight);
103
+ container.querySelectorAll('.compare-column').forEach(col => {
104
+ if (col !== source) col.scrollTop = pct * (col.scrollHeight - col.clientHeight);
105
+ });
106
+ }
107
+
108
+ document.addEventListener('keydown', function(e) {
109
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA') return;
110
+ const page = document.body.getAttribute('data-page');
111
+ if (e.key === '1' && page === 'recommend') {
112
+ const btn = document.querySelector('.btn-approach[data-approach="cf"]');
113
+ if (btn) { btn.click(); toast('Selected Collaborative Filtering', 'info', 1500); }
114
+ }
115
+ if (e.key === '2' && page === 'recommend') {
116
+ const btn = document.querySelector('.btn-approach[data-approach="content"]');
117
+ if (btn) { btn.click(); toast('Selected Content-Based', 'info', 1500); }
118
+ }
119
+ if (e.key === '3' && page === 'recommend') {
120
+ const btn = document.querySelector('.btn-approach[data-approach="knowledge"]');
121
+ if (btn) { btn.click(); toast('Selected Knowledge-Based', 'info', 1500); }
122
+ }
123
+ if ((e.key === 'r' || e.key === 'Enter') && page === 'recommend') {
124
+ const genBtn = document.querySelector('.btn-primary');
125
+ if (genBtn && !genBtn.disabled) genBtn.click();
126
+ }
127
+ if (e.key === 'e' && page !== 'evaluate') { window.location.href = '/evaluate'; }
128
+ if (e.key === 'h' && page !== 'home') { window.location.href = '/'; }
129
+ });
130
+
131
+ {% block page_globals %}{% endblock %}
132
+ </script>
133
+
134
+ {% block scripts %}{% endblock %}
135
+ </body>
136
+ </html>
templates/evaluation.html CHANGED
@@ -1,333 +1,490 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>TasteEngine Evaluation</title>
7
- <link rel="preconnect" href="https://fonts.googleapis.com">
8
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
9
- <link rel="stylesheet" href="/static/css/style.css">
10
- <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
11
- </head>
12
- <body>
13
- <nav class="nav">
14
- <a href="/" class="nav-brand">Taste<span>Engine</span></a>
15
- <div class="nav-links">
16
- <a href="/">Home</a>
17
- <a href="/recommend">Recommend</a>
18
- <a href="/evaluate" class="active">Evaluate</a>
19
- </div>
20
- </nav>
21
-
22
- <div class="container">
23
- <div class="glass">
24
- <h2 class="glass-header">📊 Evaluation & Comparison Dashboard</h2>
25
- <p style="color: rgba(255,255,255,0.6); font-size: 0.9rem;">Comprehensive analysis of all recommendation methods and approaches using 6 evaluation metrics.</p>
26
- <div class="mt-2">
27
- <button class="btn btn-primary" onclick="runEvaluation()">▶️ Run Evaluation</button>
28
- </div>
29
- </div>
30
-
31
- <div id="evalLoading" class="loading hidden">
32
- <div class="spinner"></div>
33
- <span>Running evaluation...</span>
34
- </div>
35
-
36
- <div id="evalResults" class="hidden">
37
-
38
- <div class="glass">
39
- <h2 class="glass-header">🤝 Collaborative Filtering — Method Comparison</h2>
40
- <div style="overflow-x: auto;">
41
- <table class="eval-table" id="cfTable">
42
- <thead>
43
- <tr>
44
- <th>Method</th>
45
- <th>RMSE </th>
46
- <th>MAE </th>
47
- <th>Precision@5 ↑</th>
48
- <th>Recall@5 ↑</th>
49
- <th>F1@5 </th>
50
- <th>Coverage </th>
51
- </tr>
52
- </thead>
53
- <tbody id="cfTableBody">
54
- <tr><td colspan="7" style="text-align:center;color:rgba(255,255,255,0.4);">Click "Run Evaluation" to see results</td></tr>
55
- </tbody>
56
- </table>
57
- </div>
58
- <div id="bestCfBadge" class="mt-2 hidden">
59
- <span class="badge-best">🏆 Best CF Method: <span id="bestCfName"></span></span>
60
- </div>
61
-
62
- <div class="mt-2 chart-container">
63
- <canvas id="cfChart"></canvas>
64
- </div>
65
- </div>
66
-
67
- <div class="glass">
68
- <h2 class="glass-header">📈 Approach Comparison (CF vs Content-Based vs Knowledge-Based)</h2>
69
- <div style="overflow-x: auto;">
70
- <table class="eval-table" id="approachTable">
71
- <thead>
72
- <tr>
73
- <th>Approach</th>
74
- <th>Precision@5 ↑</th>
75
- <th>Recall@5 ↑</th>
76
- </tr>
77
- </thead>
78
- <tbody id="approachTableBody">
79
- <tr><td colspan="3" style="text-align:center;color:rgba(255,255,255,0.4);">Click "Run Evaluation" to see results</td></tr>
80
- </tbody>
81
- </table>
82
- </div>
83
- </div>
84
-
85
- <div class="glass" id="analysisSection" class="hidden">
86
- <h2 class="glass-header">🔍 Analysis & Insights</h2>
87
-
88
- <div class="analysis-block">
89
- <h4>Which method performs best?</h4>
90
- <p id="analysisBestMethod">Run evaluation to find out.</p>
91
- </div>
92
-
93
- <div class="analysis-block mt-2">
94
- <h4>Which approach performs best?</h4>
95
- <p id="analysisBestApproach">Run evaluation to find out.</p>
96
- </div>
97
-
98
- <div class="analysis-block mt-2">
99
- <h4>Under what conditions does each perform better?</h4>
100
- <p id="analysisConditions">Run evaluation to find out.</p>
101
- </div>
102
-
103
- <div class="analysis-block mt-2">
104
- <h4>Why do differences occur?</h4>
105
- <p id="analysisWhy">Run evaluation to find out.</p>
106
- </div>
107
- </div>
108
-
109
- </div>
110
  </div>
111
 
112
- <script>
113
- let cfChartInstance = null;
114
-
115
- function runEvaluation() {
116
- document.getElementById('evalLoading').classList.remove('hidden');
117
- document.getElementById('evalResults').classList.add('hidden');
118
-
119
- fetch('/api/evaluate')
120
- .then(r => r.json())
121
- .then(data => {
122
- document.getElementById('evalLoading').classList.add('hidden');
123
- document.getElementById('evalResults').classList.remove('hidden');
124
-
125
- if (data.error) {
126
- document.getElementById('cfTableBody').innerHTML =
127
- `<tr><td colspan="7" style="text-align:center;color:#FF6584;">Error: ${data.error}</td></tr>`;
128
- return;
129
- }
130
-
131
- renderCfTable(data);
132
- renderCfChart(data);
133
- renderApproachTable(data);
134
- renderAnalysis(data);
135
- })
136
- .catch(err => {
137
- document.getElementById('evalLoading').classList.add('hidden');
138
- document.getElementById('evalResults').classList.remove('hidden');
139
- document.getElementById('cfTableBody').innerHTML =
140
- `<tr><td colspan="7" style="text-align:center;color:#FF6584;">Error: ${err.message}</td></tr>`;
141
- });
142
- }
143
-
144
- function renderCfTable(data) {
145
- const tbody = document.getElementById('cfTableBody');
146
- tbody.innerHTML = '';
147
-
148
- if (!data.cf_methods || data.cf_methods.length === 0) {
149
- tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:rgba(255,255,255,0.4);">No CF methods evaluated</td></tr>`;
150
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  }
152
-
153
- const methodLabels = {
154
- user_based: 'User-Based',
155
- item_based: 'Item-Based',
156
- svd: 'SVD',
157
- knn: 'KNN',
158
- slope_one: 'Slope One'
159
- };
160
-
161
- let bestMethod = null;
162
- let bestRmse = Infinity;
163
-
164
- data.cf_methods.forEach(row => {
165
- if (row.error) {
166
- const tr = document.createElement('tr');
167
- tr.innerHTML = `<td>${row.method}</td><td colspan="6" style="color:#FF6584;">Error: ${row.error}</td>`;
168
- tbody.appendChild(tr);
169
- return;
170
- }
171
-
172
- if (row.RMSE < bestRmse) {
173
- bestRmse = row.RMSE;
174
- bestMethod = row.method;
175
- }
176
-
177
- const isBest = row.method === data.best_cf_method;
178
- const tr = document.createElement('tr');
179
- if (isBest) tr.className = 'best-row';
180
- tr.innerHTML = `
181
- <td><strong>${methodLabels[row.method] || row.method}</strong> ${isBest ? '<span class="badge-best">BEST</span>' : ''}</td>
182
- <td>${row.RMSE.toFixed(4)}</td>
183
- <td>${row.MAE.toFixed(4)}</td>
184
- <td>${row['Precision@5'].toFixed(4)}</td>
185
- <td>${row['Recall@5'].toFixed(4)}</td>
186
- <td>${row['F1@5'].toFixed(4)}</td>
187
- <td>${row.Coverage.toFixed(4)}</td>
188
- `;
189
- tbody.appendChild(tr);
190
- });
191
-
192
- if (data.best_cf_method) {
193
- document.getElementById('bestCfBadge').classList.remove('hidden');
194
- document.getElementById('bestCfName').textContent =
195
- (methodLabels[data.best_cf_method] || data.best_cf_method) +
196
- ` (RMSE: ${bestRmse.toFixed(4)})`;
197
  }
198
- }
199
 
200
- function renderCfChart(data) {
201
- if (!data.cf_methods || data.cf_methods.length === 0) return;
202
-
203
- const methods = data.cf_methods.filter(r => !r.error);
204
- const methodLabels = { user_based: 'User-Based', item_based: 'Item-Based', svd: 'SVD', knn: 'KNN', slope_one: 'Slope One' };
205
-
206
- const labels = methods.map(r => methodLabels[r.method] || r.method);
207
- const rmseData = methods.map(r => r.RMSE);
208
- const maeData = methods.map(r => r.MAE);
209
-
210
- if (cfChartInstance) cfChartInstance.destroy();
211
-
212
- const ctx = document.getElementById('cfChart').getContext('2d');
213
- cfChartInstance = new Chart(ctx, {
214
- type: 'bar',
215
- data: {
216
- labels: labels,
217
- datasets: [
218
- {
219
- label: 'RMSE (lower is better)',
220
- data: rmseData,
221
- backgroundColor: 'rgba(108, 99, 255, 0.6)',
222
- borderColor: '#6C63FF',
223
- borderWidth: 1
224
- },
225
- {
226
- label: 'MAE (lower is better)',
227
- data: maeData,
228
- backgroundColor: 'rgba(255, 101, 132, 0.6)',
229
- borderColor: '#FF6584',
230
- borderWidth: 1
231
- }
232
- ]
233
- },
234
- options: {
235
- responsive: true,
236
- maintainAspectRatio: false,
237
- plugins: {
238
- legend: {
239
- labels: { color: 'rgba(255,255,255,0.7)' }
240
- }
241
- },
242
- scales: {
243
- x: {
244
- ticks: { color: 'rgba(255,255,255,0.5)' },
245
- grid: { color: 'rgba(255,255,255,0.05)' }
246
- },
247
- y: {
248
- beginAtZero: true,
249
- ticks: { color: 'rgba(255,255,255,0.5)' },
250
- grid: { color: 'rgba(255,255,255,0.05)' }
251
- }
252
- }
253
- }
254
- });
255
  }
256
-
257
- function renderApproachTable(data) {
258
- const tbody = document.getElementById('approachTableBody');
259
- tbody.innerHTML = '';
260
-
261
- if (!data.approaches || data.approaches.length === 0) {
262
- tbody.innerHTML = `<tr><td colspan="3" style="text-align:center;color:rgba(255,255,255,0.4);">Approach comparison requires more data. Try running again.</td></tr>`;
263
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  }
 
 
 
265
 
266
- data.approaches.forEach(row => {
267
- if (row.error) {
268
- const tr = document.createElement('tr');
269
- tr.innerHTML = `<td>${row.approach}</td><td colspan="2" style="color:#FF6584;">${row.error}</td>`;
270
- tbody.appendChild(tr);
271
- return;
272
- }
273
- const isBest = data.best_approach && row.approach === data.best_approach;
274
- const tr = document.createElement('tr');
275
- if (isBest) tr.className = 'best-row';
276
- tr.innerHTML = `
277
- <td><strong>${row.approach}</strong> ${isBest ? '<span class="badge-best">BEST</span>' : ''}</td>
278
- <td>${row['Precision@5']?.toFixed(4) || 'N/A'}</td>
279
- <td>${row['Recall@5']?.toFixed(4) || 'N/A'}</td>
280
- `;
281
- tbody.appendChild(tr);
282
- });
283
- }
284
 
285
- function renderAnalysis(data) {
286
- document.getElementById('analysisSection').classList.remove('hidden');
287
 
288
- const methodLabels = { user_based: 'User-Based', item_based: 'Item-Based', svd: 'SVD', knn: 'KNN', slope_one: 'Slope One' };
289
 
290
- if (data.best_cf_method) {
291
- const best = data.cf_methods.find(r => r.method === data.best_cf_method);
292
- const rmseVal = best ? best.RMSE.toFixed(4) : '—';
293
- document.getElementById('analysisBestMethod').innerHTML =
294
- `<strong>${methodLabels[data.best_cf_method] || data.best_cf_method}</strong> achieves the lowest RMSE (${rmseVal}) among all CF methods. ` +
295
- `SVD (Matrix Factorization) typically performs best because it captures latent factors in the user-item interaction matrix, ` +
296
- `handling sparsity better than memory-based methods like User-Based or Item-Based CF.`;
297
- }
298
 
299
- if (data.best_approach && data.approaches && data.approaches.length > 0) {
300
- const best = data.approaches.find(a => a.approach === data.best_approach);
301
- const precVal = best ? best['Precision@5'].toFixed(4) : '—';
302
- document.getElementById('analysisBestApproach').innerHTML =
303
- `<strong>${data.best_approach}</strong> achieves the highest Precision@5 (${precVal}) on this dataset. ` +
304
- `Collaborative Filtering generally performs best when sufficient rating data exists. ` +
305
- `Content-Based works well for new items but suffers from overspecialization. ` +
306
- `Knowledge-Based excels in cold-start scenarios and when users have explicit constraints.`;
307
- } else {
308
- document.getElementById('analysisBestApproach').textContent = 'Run evaluation to compare approaches.';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  }
310
-
311
- document.getElementById('analysisConditions').innerHTML = `
312
- <b>• Dense user data:</b> Collaborative Filtering (leverages peer patterns)<br>
313
- <b>• Cold-start user:</b> Knowledge-Based (no history needed)<br>
314
- <b>• Cold-start item:</b> Content-Based (matches item features)<br>
315
- <b>• Explicit constraints:</b> Knowledge-Based (precise filtering)<br>
316
- <b>• Niche categories:</b> Content-Based (item features override sparsity)
317
- `;
318
-
319
- document.getElementById('analysisWhy').innerHTML =
320
- `Differences arise from algorithmic biases: CF relies on the collective behavior of users, ` +
321
- `making it powerful for popular items but weak for new users/items. Content-Based depends on ` +
322
- `feature representation quality and tends to overspecialize. Knowledge-Based is deterministic ` +
323
- `and transparent but requires explicit user input and domain rules. The choice depends on data ` +
324
- `availability, user context, and the specific recommendation goal.`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  }
326
-
327
- if (localStorage.getItem('autoEval') === 'true') {
328
- localStorage.removeItem('autoEval');
329
- runEvaluation();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  }
331
- </script>
332
- </body>
333
- </html>
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}TasteEngine — Evaluation{% endblock %}
3
+
4
+ {% block head_extra %}
5
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
6
+ {% endblock %}
7
+
8
+ {% block breadcrumb %}
9
+ <ul class="breadcrumb">
10
+ <li><a href="/">Home</a><span class="sep">/</span></li>
11
+ <li class="current">Evaluate</li>
12
+ </ul>
13
+ {% endblock %}
14
+
15
+ {% block content %}
16
+ {% from "macros.html" import glass, spinner %}
17
+
18
+ <div x-data="evalApp()">
19
+ {% call glass("Evaluation & Comparison Dashboard") %}
20
+ <p class="text-muted" style="font-size: 0.9rem;">
21
+ Comprehensive analysis of all recommendation methods and approaches using 6 evaluation metrics.
22
+ </p>
23
+ <div class="mt-2">
24
+ <button class="btn btn-primary" @click="runEvaluation()" :disabled="running">
25
+ <span x-show="!running">Run Evaluation</span>
26
+ <span x-show="running">Running...</span>
27
+ </button>
28
+ </div>
29
+ {% endcall %}
30
+
31
+ <div x-show="running" x-transition>
32
+ {{ spinner("Running evaluation...") }}
33
+ </div>
34
+
35
+ <div x-show="hasResults" x-cloak x-transition.duration.400ms>
36
+
37
+ <div class="kpi-grid">
38
+ <div class="kpi-card kpi-primary">
39
+ <div class="kpi-label">Best RMSE</div>
40
+ <div class="kpi-value"><span x-text="animatedKpis.bestRmse"></span></div>
41
+ <div class="kpi-unit" x-text="'via ' + methodLabel(bestCfMethod)"></div>
42
+ </div>
43
+ <div class="kpi-card kpi-success">
44
+ <div class="kpi-label">Best Method</div>
45
+ <div class="kpi-value" style="font-size:1rem;" x-text="methodLabel(bestCfMethod)"></div>
46
+ <div class="kpi-unit">Lowest RMSE</div>
47
+ </div>
48
+ <div class="kpi-card kpi-secondary">
49
+ <div class="kpi-label">Coverage</div>
50
+ <div class="kpi-value"><span x-text="animatedKpis.coverage"></span></div>
51
+ <div class="kpi-unit" x-text="'by ' + methodLabel(bestCfMethod)"></div>
52
+ </div>
53
+ <div class="kpi-card kpi-warning">
54
+ <div class="kpi-label">Best Precision@5</div>
55
+ <div class="kpi-value"><span x-text="animatedKpis.bestPrecision"></span></div>
56
+ <div class="kpi-unit" x-text="bestApproach ? bestApproach : '—'"></div>
57
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  </div>
59
 
60
+ {% call glass("Collaborative Filtering — Method Comparison") %}
61
+ <div class="flex-between mb-2">
62
+ <span></span>
63
+ <button class="btn btn-ghost btn-sm" @click="exportCSV()">Export CSV</button>
64
+ </div>
65
+ <div style="overflow-x: auto;">
66
+ <table class="eval-table">
67
+ <thead>
68
+ <tr>
69
+ <th>Method</th>
70
+ <th>RMSE ↓</th>
71
+ <th>MAE ↓</th>
72
+ <th>Precision@5 ↑</th>
73
+ <th>Recall@5 ↑</th>
74
+ <th>F1@5 ↑</th>
75
+ <th>Coverage </th>
76
+ </tr>
77
+ </thead>
78
+ <tbody>
79
+ <template x-for="row in cfData" :key="row.method">
80
+ <tr :class="{ 'best-row': row.method === bestCfMethod }">
81
+ <td>
82
+ <strong x-text="methodLabel(row.method)"></strong>
83
+ <span x-show="row.method === bestCfMethod" class="badge-best">BEST</span>
84
+ </td>
85
+ <td x-text="row.RMSE?.toFixed(4)"></td>
86
+ <td x-text="row.MAE?.toFixed(4)"></td>
87
+ <td x-text="row['Precision@5']?.toFixed(4)"></td>
88
+ <td x-text="row['Recall@5']?.toFixed(4)"></td>
89
+ <td x-text="row['F1@5']?.toFixed(4)"></td>
90
+ <td x-text="row.Coverage?.toFixed(4)"></td>
91
+ </tr>
92
+ </template>
93
+ </tbody>
94
+ </table>
95
+ </div>
96
+
97
+ <div class="mt-2" x-show="bestCfMethod">
98
+ <span class="badge-best">Best CF Method: <span x-text="methodLabel(bestCfMethod)"></span></span>
99
+ </div>
100
+
101
+ <div class="mt-2 chart-container">
102
+ <canvas id="cfChart"></canvas>
103
+ </div>
104
+ {% endcall %}
105
+
106
+ {% call glass("Approach Comparison (CF vs Content-Based vs Knowledge-Based)") %}
107
+ <div style="overflow-x: auto;">
108
+ <table class="eval-table">
109
+ <thead>
110
+ <tr>
111
+ <th>Approach</th>
112
+ <th>Precision@5 ↑</th>
113
+ <th>Recall@5 ↑</th>
114
+ </tr>
115
+ </thead>
116
+ <tbody>
117
+ <template x-for="row in approachData" :key="row.approach">
118
+ <tr :class="{ 'best-row': row.approach === bestApproach }">
119
+ <td>
120
+ <strong x-text="row.approach"></strong>
121
+ <span x-show="row.approach === bestApproach" class="badge-best">BEST</span>
122
+ </td>
123
+ <td x-text="row['Precision@5']?.toFixed(4) || 'N/A'"></td>
124
+ <td x-text="row['Recall@5']?.toFixed(4) || 'N/A'"></td>
125
+ </tr>
126
+ </template>
127
+ </tbody>
128
+ </table>
129
+ </div>
130
+
131
+ <div class="mt-2 chart-container">
132
+ <canvas id="approachChart"></canvas>
133
+ </div>
134
+ {% endcall %}
135
+
136
+ {% call glass("Multi-Method Radar Comparison") %}
137
+ <p class="text-muted" style="font-size: 0.85rem; margin-bottom: 1rem;">
138
+ Normalized comparison across all metrics for every CF method.
139
+ </p>
140
+ <div class="chart-container">
141
+ <canvas id="radarChart"></canvas>
142
+ </div>
143
+ {% endcall %}
144
+
145
+ {% call glass("Analysis & Insights") %}
146
+ <div class="analysis-block">
147
+ <h4>Which method performs best?</h4>
148
+ <p x-html="analysis.bestMethod"></p>
149
+ </div>
150
+ <div class="analysis-block mt-2">
151
+ <h4>Which approach performs best?</h4>
152
+ <p x-html="analysis.bestApproach"></p>
153
+ </div>
154
+ <div class="analysis-block mt-2">
155
+ <h4>Under what conditions does each perform better?</h4>
156
+ <ul>
157
+ <li><strong>Dense user data:</strong> Collaborative Filtering (leverages peer patterns)</li>
158
+ <li><strong>Cold-start user:</strong> Knowledge-Based (no history needed)</li>
159
+ <li><strong>Cold-start item:</strong> Content-Based (matches item features)</li>
160
+ <li><strong>Explicit constraints:</strong> Knowledge-Based (precise filtering)</li>
161
+ <li><strong>Niche categories:</strong> Content-Based (item features override sparsity)</li>
162
+ </ul>
163
+ </div>
164
+ <div class="analysis-block mt-2">
165
+ <h4>Why do differences occur?</h4>
166
+ <p>Differences arise from algorithmic biases: CF relies on the collective behavior of users, making it powerful for popular items but weak for new users/items. Content-Based depends on feature representation quality and tends to overspecialize. Knowledge-Based is deterministic and transparent but requires explicit user input and domain rules. The choice depends on data availability, user context, and the specific recommendation goal.</p>
167
+ </div>
168
+ {% endcall %}
169
+
170
+ </div>
171
+ </div>
172
+ {% endblock %}
173
+
174
+ {% block scripts %}
175
+ <script>
176
+ const methodLabels = {
177
+ user_based: 'User-Based',
178
+ item_based: 'Item-Based',
179
+ svd: 'SVD',
180
+ knn: 'KNN',
181
+ slope_one: 'Slope One'
182
+ };
183
+
184
+ document.addEventListener('alpine:init', () => {
185
+ Alpine.data('evalApp', () => ({
186
+ running: false,
187
+ hasResults: false,
188
+ cfData: [],
189
+ approachData: [],
190
+ bestCfMethod: null,
191
+ bestApproach: null,
192
+ cfChartInstance: null,
193
+ approachChartInstance: null,
194
+ radarChartInstance: null,
195
+ analysis: {
196
+ bestMethod: 'Run evaluation to find out.',
197
+ bestApproach: 'Run evaluation to find out.'
198
+ },
199
+ animatedKpis: { bestRmse: '—', coverage: '—', bestPrecision: '—' },
200
+
201
+ methodLabel(id) {
202
+ return methodLabels[id] || id;
203
+ },
204
+
205
+ async runEvaluation() {
206
+ this.running = true;
207
+
208
+ try {
209
+ const r = await fetch('/api/evaluate');
210
+ const data = await r.json();
211
+
212
+ if (data.error) {
213
+ toast('Error: ' + data.error, 'error');
214
+ return;
215
+ }
216
+
217
+ this.cfData = data.cf_methods || [];
218
+ this.approachData = data.approaches || [];
219
+ this.bestCfMethod = data.best_cf_method;
220
+ this.bestApproach = data.best_approach;
221
+ this.hasResults = true;
222
+
223
+ this.$nextTick(() => {
224
+ this.renderCharts(data);
225
+ this.renderAnalysis(data);
226
+
227
+ const best = data.cf_methods?.find(r => r.method === data.best_cf_method);
228
+ if (best) {
229
+ this.animatedKpis.bestRmse = '0.0000';
230
+ this.animateCounter(this.animatedKpis, 'bestRmse', best.RMSE, 4, 1200);
231
+ this.animatedKpis.coverage = '0.0000';
232
+ this.animateCounter(this.animatedKpis, 'coverage', best.Coverage, 4, 1200);
233
  }
234
+ if (data.best_approach) {
235
+ const bestApp = data.approaches?.find(a => a.approach === data.best_approach);
236
+ if (bestApp) {
237
+ this.animatedKpis.bestPrecision = '0.0000';
238
+ this.animateCounter(this.animatedKpis, 'bestPrecision', bestApp['Precision@5'] || 0, 4, 1200);
239
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  }
241
+ });
242
 
243
+ toast('Evaluation complete', 'success');
244
+ } catch (err) {
245
+ toast('Error: ' + err.message, 'error');
246
+ } finally {
247
+ this.running = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  }
249
+ },
250
+
251
+ renderCharts(data) {
252
+ this.renderCfChart(data);
253
+ this.renderApproachChart(data);
254
+ this.renderRadarChart(data);
255
+ },
256
+
257
+ renderCfChart(data) {
258
+ if (this.cfChartInstance) this.cfChartInstance.destroy();
259
+
260
+ const methods = (data.cf_methods || []).filter(r => !r.error);
261
+ if (methods.length === 0) return;
262
+
263
+ const labels = methods.map(r => methodLabels[r.method] || r.method);
264
+ const ctx = document.getElementById('cfChart').getContext('2d');
265
+
266
+ this.cfChartInstance = new Chart(ctx, {
267
+ type: 'bar',
268
+ data: {
269
+ labels: labels,
270
+ datasets: [
271
+ {
272
+ label: 'RMSE (lower is better)',
273
+ data: methods.map(r => r.RMSE),
274
+ backgroundColor: 'rgba(108, 99, 255, 0.6)',
275
+ borderColor: '#6C63FF',
276
+ borderWidth: 1
277
+ },
278
+ {
279
+ label: 'MAE (lower is better)',
280
+ data: methods.map(r => r.MAE),
281
+ backgroundColor: 'rgba(255, 101, 132, 0.6)',
282
+ borderColor: '#FF6584',
283
+ borderWidth: 1
284
+ }
285
+ ]
286
+ },
287
+ options: {
288
+ responsive: true,
289
+ maintainAspectRatio: false,
290
+ plugins: {
291
+ legend: {
292
+ labels: { color: 'rgba(255,255,255,0.7)' }
293
+ }
294
+ },
295
+ scales: {
296
+ x: {
297
+ ticks: { color: 'rgba(255,255,255,0.5)' },
298
+ grid: { color: 'rgba(255,255,255,0.05)' }
299
+ },
300
+ y: {
301
+ beginAtZero: true,
302
+ ticks: { color: 'rgba(255,255,255,0.5)' },
303
+ grid: { color: 'rgba(255,255,255,0.05)' }
304
+ }
305
+ }
306
+ }
307
+ });
308
+ },
309
+
310
+ renderApproachChart(data) {
311
+ if (this.approachChartInstance) this.approachChartInstance.destroy();
312
+
313
+ const approaches = data.approaches || [];
314
+ if (approaches.length === 0) return;
315
+
316
+ const ctx = document.getElementById('approachChart').getContext('2d');
317
+
318
+ this.approachChartInstance = new Chart(ctx, {
319
+ type: 'bar',
320
+ data: {
321
+ labels: approaches.map(r => r.approach),
322
+ datasets: [
323
+ {
324
+ label: 'Precision@5',
325
+ data: approaches.map(r => r['Precision@5'] || 0),
326
+ backgroundColor: 'rgba(108, 99, 255, 0.6)',
327
+ borderColor: '#6C63FF',
328
+ borderWidth: 1
329
+ },
330
+ {
331
+ label: 'Recall@5',
332
+ data: approaches.map(r => r['Recall@5'] || 0),
333
+ backgroundColor: 'rgba(255, 101, 132, 0.6)',
334
+ borderColor: '#FF6584',
335
+ borderWidth: 1
336
+ }
337
+ ]
338
+ },
339
+ options: {
340
+ responsive: true,
341
+ maintainAspectRatio: false,
342
+ plugins: {
343
+ legend: {
344
+ labels: { color: 'rgba(255,255,255,0.7)' }
345
+ }
346
+ },
347
+ scales: {
348
+ x: {
349
+ ticks: { color: 'rgba(255,255,255,0.5)' },
350
+ grid: { color: 'rgba(255,255,255,0.05)' }
351
+ },
352
+ y: {
353
+ beginAtZero: true,
354
+ ticks: { color: 'rgba(255,255,255,0.5)' },
355
+ grid: { color: 'rgba(255,255,255,0.05)' }
356
+ }
357
  }
358
+ }
359
+ });
360
+ },
361
 
362
+ renderRadarChart(data) {
363
+ if (this.radarChartInstance) this.radarChartInstance.destroy();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
 
365
+ const methods = (data.cf_methods || []).filter(r => !r.error);
366
+ if (methods.length === 0) return;
367
 
368
+ const metrics = ['RMSE', 'MAE', 'Precision@5', 'Recall@5', 'F1@5', 'Coverage'];
369
 
370
+ function normalize(values) {
371
+ const mn = Math.min(...values);
372
+ const mx = Math.max(...values);
373
+ return mx === mn ? values.map(() => 1) : values.map(v => (v - mn) / (mx - mn));
374
+ }
 
 
 
375
 
376
+ const normalized = metrics.map(metric => ({
377
+ label: metric,
378
+ values: normalize(methods.map(m => m[metric] || 0))
379
+ }));
380
+
381
+ const colors = [
382
+ 'rgba(108, 99, 255, 0.3)',
383
+ 'rgba(255, 101, 132, 0.3)',
384
+ 'rgba(34, 197, 94, 0.3)',
385
+ 'rgba(245, 158, 11, 0.3)',
386
+ 'rgba(59, 130, 246, 0.3)'
387
+ ];
388
+ const borders = [
389
+ '#6C63FF', '#FF6584', '#22C55E', '#F59E0B', '#3B82F6'
390
+ ];
391
+
392
+ const ctx = document.getElementById('radarChart').getContext('2d');
393
+
394
+ this.radarChartInstance = new Chart(ctx, {
395
+ type: 'radar',
396
+ data: {
397
+ labels: metrics,
398
+ datasets: methods.map((m, i) => ({
399
+ label: methodLabels[m.method] || m.method,
400
+ data: metrics.map(metric => {
401
+ const vals = methods.map(x => x[metric] || 0);
402
+ const mn = Math.min(...vals);
403
+ const mx = Math.max(...vals);
404
+ return mx === mn ? 1 : ((m[metric] || 0) - mn) / (mx - mn);
405
+ }),
406
+ backgroundColor: colors[i % colors.length],
407
+ borderColor: borders[i % borders.length],
408
+ borderWidth: 2,
409
+ pointBackgroundColor: borders[i % borders.length]
410
+ }))
411
+ },
412
+ options: {
413
+ responsive: true,
414
+ maintainAspectRatio: false,
415
+ plugins: {
416
+ legend: {
417
+ labels: { color: 'rgba(255,255,255,0.7)' }
418
+ }
419
+ },
420
+ scales: {
421
+ r: {
422
+ beginAtZero: true,
423
+ max: 1,
424
+ ticks: {
425
+ color: 'rgba(255,255,255,0.4)',
426
+ backdropColor: 'transparent'
427
+ },
428
+ grid: { color: 'rgba(255,255,255,0.08)' },
429
+ angleLines: { color: 'rgba(255,255,255,0.08)' },
430
+ pointLabels: {
431
+ color: 'rgba(255,255,255,0.7)',
432
+ font: { size: 11 }
433
+ }
434
+ }
435
  }
436
+ }
437
+ });
438
+ },
439
+
440
+ animateCounter(obj, key, target, decimals = 4, duration = 1000) {
441
+ const start = performance.now();
442
+ const step = (now) => {
443
+ const elapsed = now - start;
444
+ const pct = Math.min(elapsed / duration, 1);
445
+ const eased = 1 - Math.pow(1 - pct, 3);
446
+ obj[key] = (target * eased).toFixed(decimals);
447
+ if (pct < 1) requestAnimationFrame(step);
448
+ };
449
+ requestAnimationFrame(step);
450
+ },
451
+
452
+ exportCSV() {
453
+ if (!this.cfData.length) { toast('No data to export', 'warning'); return; }
454
+ let csv = 'Method,RMSE,MAE,Precision@5,Recall@5,F1@5,Coverage\n';
455
+ const labels = { user_based: 'User-Based', item_based: 'Item-Based', svd: 'SVD', knn: 'KNN', slope_one: 'Slope One' };
456
+ this.cfData.forEach(r => {
457
+ if (!r.error) {
458
+ csv += `${labels[r.method] || r.method},${r.RMSE},${r.MAE},${r['Precision@5']},${r['Recall@5']},${r['F1@5']},${r.Coverage}\n`;
459
+ }
460
+ });
461
+ if (this.approachData.length) {
462
+ csv += '\nApproach,Precision@5,Recall@5\n';
463
+ this.approachData.forEach(r => {
464
+ csv += `${r.approach},${r['Precision@5'] || 'N/A'},${r['Recall@5'] || 'N/A'}\n`;
465
+ });
466
  }
467
+ const blob = new Blob([csv], { type: 'text/csv' });
468
+ const url = URL.createObjectURL(blob);
469
+ const a = document.createElement('a');
470
+ a.href = url; a.download = 'tasteengine_evaluation.csv';
471
+ a.click(); URL.revokeObjectURL(url);
472
+ toast('CSV exported', 'success');
473
+ },
474
+
475
+ renderAnalysis(data) {
476
+ const best = data.cf_methods?.find(r => r.method === data.best_cf_method);
477
+ const rmseVal = best ? best.RMSE.toFixed(4) : '—';
478
+
479
+ this.analysis.bestMethod = `<strong>${methodLabels[data.best_cf_method] || data.best_cf_method}</strong> achieves the lowest RMSE (${rmseVal}) among all CF methods. SVD (Matrix Factorization) typically performs best because it captures latent factors in the user-item interaction matrix, handling sparsity better than memory-based methods.`;
480
+
481
+ if (data.best_approach) {
482
+ const bestApp = data.approaches?.find(a => a.approach === data.best_approach);
483
+ const precVal = bestApp ? bestApp['Precision@5'].toFixed(4) : '—';
484
+ this.analysis.bestApproach = `<strong>${data.best_approach}</strong> achieves the highest Precision@5 (${precVal}) on this dataset. Collaborative Filtering generally performs best when sufficient rating data exists. Content-Based works well for new items but suffers from overspecialization. Knowledge-Based excels in cold-start scenarios and when users have explicit constraints.`;
485
  }
486
+ }
487
+ }));
488
+ });
489
+ </script>
490
+ {% endblock %}
templates/index.html CHANGED
@@ -1,132 +1,162 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>TasteEngine — Recommender System</title>
7
- <link rel="preconnect" href="https://fonts.googleapis.com">
8
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
9
- <link rel="stylesheet" href="/static/css/style.css">
10
- </head>
11
- <body>
12
- <nav class="nav">
13
- <a href="/" class="nav-brand">Taste<span>Engine</span></a>
14
- <div class="nav-links">
15
- <a href="/" class="active">Home</a>
16
- <a href="/recommend">Recommend</a>
17
- <a href="/evaluate">Evaluate</a>
18
- </div>
19
- </nav>
20
 
21
- <div class="container">
22
- <div class="glass">
23
- <h1 class="glass-header">Welcome to TasteEngine</h1>
24
- <p style="color: rgba(255,255,255,0.6); font-size: 0.9rem;">An intelligent multi-approach recommender system. Compare Collaborative Filtering, Content-Based, and Knowledge-Based recommendations side by side.</p>
25
- </div>
26
 
27
- <div class="row">
28
- <div class="glass" id="userSelectPanel">
29
- <h2 class="glass-header">Select a User</h2>
30
- <div class="form-group">
31
- <label class="form-label">User</label>
32
- <select id="userSelect">
33
- <option value="">-- Choose a user --</option>
34
- {% for u in users %}
35
- <option value="{{ u.id }}">{{ u.name }} (Age: {{ u.age }})</option>
36
- {% endfor %}
37
- </select>
38
- </div>
39
- <div id="profileCard" class="profile-card mt-2 hidden">
40
- <div class="profile-avatar" id="profileAvatar">A</div>
41
- <div class="profile-info">
42
- <div class="profile-name" id="profileName">Alice</div>
43
- <div class="profile-detail" id="profileDetail">Age: 28</div>
44
- <div class="profile-detail">Budget: $<span id="profileBudgetMin">0</span> - $<span id="profileBudgetMax">0</span></div>
45
- <div class="profile-tags mt-1" id="profileTags"></div>
46
- </div>
47
- </div>
48
- </div>
49
 
50
- <div class="glass">
51
- <h2 class="glass-header">Quick Actions</h2>
52
- <p style="color: rgba(255,255,255,0.6); font-size: 0.85rem; margin-bottom: 1rem;">Get started by selecting a user above, then:</p>
53
- <a href="/recommend" class="btn btn-primary" style="display: inline-block; text-decoration: none; margin-bottom: 0.5rem;">🔍 Get Recommendations</a>
54
- <br>
55
- <a href="/evaluate" class="btn btn-secondary" style="display: inline-block; text-decoration: none;">📊 View Evaluation</a>
56
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  </div>
 
 
58
 
59
- <div class="glass">
60
- <h2 class="glass-header">Available Approaches</h2>
61
- <div class="row" style="grid-template-columns: 1fr 1fr 1fr;">
62
- <div style="text-align: center; padding: 1rem;">
63
- <div style="font-size: 2.5rem; margin-bottom: 0.5rem;">🤝</div>
64
- <h3 style="color: #fff; font-size: 1rem; margin-bottom: 0.3rem;">Collaborative Filtering</h3>
65
- <p style="color: rgba(255,255,255,0.5); font-size: 0.8rem;">5 methods: User, Item, SVD, KNN, Slope One</p>
66
- </div>
67
- <div style="text-align: center; padding: 1rem;">
68
- <div style="font-size: 2.5rem; margin-bottom: 0.5rem;">🏷️</div>
69
- <h3 style="color: #fff; font-size: 1rem; margin-bottom: 0.3rem;">Content-Based</h3>
70
- <p style="color: rgba(255,255,255,0.5); font-size: 0.8rem;">TF-IDF + Cosine Similarity, Feature Matching</p>
71
- </div>
72
- <div style="text-align: center; padding: 1rem;">
73
- <div style="font-size: 2.5rem; margin-bottom: 0.5rem;">⚙️</div>
74
- <h3 style="color: #fff; font-size: 1rem; margin-bottom: 0.3rem;">Knowledge-Based</h3>
75
- <p style="color: rgba(255,255,255,0.5); font-size: 0.8rem;">Constraint, Rule, Utility-based filtering</p>
76
- </div>
77
- </div>
78
  </div>
 
 
 
 
 
79
  </div>
 
80
 
81
- <script>
82
- const userData = {{ users | tojson | safe }};
 
 
 
 
 
 
 
83
 
84
- document.getElementById('userSelect').addEventListener('change', function() {
85
- const uid = parseInt(this.value);
86
- if (!uid) {
87
- document.getElementById('profileCard').classList.add('hidden');
88
- return;
89
- }
90
- const user = userData.find(u => u.id === uid);
91
- if (!user) return;
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
- document.getElementById('profileCard').classList.remove('hidden');
94
- document.getElementById('profileAvatar').textContent = user.name.charAt(0);
95
- document.getElementById('profileName').textContent = user.name;
96
- document.getElementById('profileDetail').textContent = `Age: ${user.age}`;
97
- document.getElementById('profileBudgetMin').textContent = user.budget_min.toFixed(2);
98
- document.getElementById('profileBudgetMax').textContent = user.budget_max.toFixed(2);
99
 
100
- const tagsContainer = document.getElementById('profileTags');
101
- tagsContainer.innerHTML = '';
102
- user.categories.forEach(cat => {
103
- if (cat) {
104
- const tag = document.createElement('span');
105
- tag.className = 'tag';
106
- tag.textContent = cat;
107
- tagsContainer.appendChild(tag);
108
- }
109
- });
110
- user.brands.forEach(b => {
111
- if (b) {
112
- const tag = document.createElement('span');
113
- tag.className = 'tag';
114
- tag.style.borderColor = 'rgba(255,101,132,0.3)';
115
- tag.style.background = 'rgba(255,101,132,0.15)';
116
- tag.style.color = '#ff9eb2';
117
- tag.textContent = b;
118
- tagsContainer.appendChild(tag);
119
- }
120
- });
121
 
122
- localStorage.setItem('selectedUser', uid);
123
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
- const savedUser = localStorage.getItem('selectedUser');
126
- if (savedUser) {
127
- document.getElementById('userSelect').value = savedUser;
128
- document.getElementById('userSelect').dispatchEvent(new Event('change'));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  }
130
- </script>
131
- </body>
132
- </html>
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}TasteEngine — Home{% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
+ {% block content %}
5
+ {% from "macros.html" import glass, tag %}
 
 
 
6
 
7
+ {% call glass("Welcome to TasteEngine") %}
8
+ <p class="text-muted" style="font-size: 0.9rem;">
9
+ An intelligent multi-approach recommender system. Compare Collaborative Filtering,
10
+ Content-Based, and Knowledge-Based recommendations side by side.
11
+ </p>
12
+ {% endcall %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ <div x-data="homeApp()" class="row">
15
+ {% call glass("Select a User") %}
16
+ <div class="form-group">
17
+ <label class="form-label" for="userSelect">User</label>
18
+ <select id="userSelect" x-model="selectedUserId" @change="onUserChange">
19
+ <option value="">-- Choose a user --</option>
20
+ {% for u in users %}
21
+ <option value="{{ u.id }}">{{ u.name }} (Age: {{ u.age }})</option>
22
+ {% endfor %}
23
+ </select>
24
+ </div>
25
+ <div class="profile-card mt-2" x-show="selectedUser" x-cloak x-transition>
26
+ <div class="profile-avatar" x-text="selectedUser?.name?.charAt(0) || '?'"></div>
27
+ <div class="profile-info">
28
+ <div class="profile-name" x-text="selectedUser?.name"></div>
29
+ <div class="profile-detail" x-text="'Age: ' + selectedUser?.age"></div>
30
+ <div class="profile-detail" x-text="'Budget: $' + selectedUser?.budget_min?.toFixed(2) + ' - $' + selectedUser?.budget_max?.toFixed(2)"></div>
31
+ <div class="profile-tags mt-1">
32
+ <template x-for="cat in (selectedUser?.categories || [])" :key="cat">
33
+ <span class="tag" x-text="cat"></span>
34
+ </template>
35
+ <template x-for="b in (selectedUser?.brands || [])" :key="b">
36
+ <span class="tag tag-brand" x-text="b"></span>
37
+ </template>
38
  </div>
39
+ </div>
40
+ </div>
41
 
42
+ <div x-show="selectedUser" x-cloak x-transition class="mt-2">
43
+ <button class="btn btn-secondary btn-sm" @click="showEditor = !showEditor">
44
+ <span x-text="showEditor ? 'Hide Preferences' : 'Edit Preferences'"></span>
45
+ </button>
46
+ </div>
47
+
48
+ <div x-show="showEditor && selectedUser" x-cloak x-transition class="mt-2">
49
+ <div class="glass" style="margin-bottom:0;padding:1rem;">
50
+ <h3 style="font-size:0.9rem;color:var(--text-primary);margin-bottom:0.75rem;">Budget Preferences</h3>
51
+ <div class="preference-slider">
52
+ <label>Min Budget</label>
53
+ <input type="range" :min="0" :max="selectedUser?.budget_max || 100" x-model.number="editBudgetMin">
54
+ <span class="slider-value" x-text="'$' + editBudgetMin"></span>
55
+ </div>
56
+ <div class="preference-slider">
57
+ <label>Max Budget</label>
58
+ <input type="range" :min="editBudgetMin" :max="500" x-model.number="editBudgetMax">
59
+ <span class="slider-value" x-text="'$' + editBudgetMax"></span>
 
60
  </div>
61
+ <button class="btn btn-primary btn-sm mt-2" @click="savePreferences()" :disabled="savingPrefs">
62
+ <span x-show="!savingPrefs">Save Preferences</span>
63
+ <span x-show="savingPrefs">Saving...</span>
64
+ </button>
65
+ </div>
66
  </div>
67
+ {% endcall %}
68
 
69
+ {% call glass("Quick Actions") %}
70
+ <p class="text-muted" style="font-size: 0.85rem; margin-bottom: 1rem;">
71
+ Get started by selecting a user above, then:
72
+ </p>
73
+ <a href="/recommend" class="btn btn-primary" style="display: inline-flex; text-decoration: none; margin-bottom: 0.5rem;">Get Recommendations</a>
74
+ <br>
75
+ <a href="/evaluate" class="btn btn-secondary" style="display: inline-flex; text-decoration: none;">View Evaluation</a>
76
+ {% endcall %}
77
+ </div>
78
 
79
+ {% call glass("Available Approaches") %}
80
+ <div class="row" style="grid-template-columns: 1fr 1fr 1fr;">
81
+ <div class="text-center" style="padding: 1rem;">
82
+ <div style="font-size: 2.5rem; margin-bottom: 0.5rem;">🤝</div>
83
+ <h3 style="color: var(--text-primary); font-size: 1rem; margin-bottom: 0.3rem;">Collaborative Filtering</h3>
84
+ <p class="text-dim" style="font-size: 0.8rem;">5 methods: User, Item, SVD, KNN, Slope One</p>
85
+ </div>
86
+ <div class="text-center" style="padding: 1rem;">
87
+ <div style="font-size: 2.5rem; margin-bottom: 0.5rem;">🏷️</div>
88
+ <h3 style="color: var(--text-primary); font-size: 1rem; margin-bottom: 0.3rem;">Content-Based</h3>
89
+ <p class="text-dim" style="font-size: 0.8rem;">TF-IDF + Cosine Similarity, Feature Matching</p>
90
+ </div>
91
+ <div class="text-center" style="padding: 1rem;">
92
+ <div style="font-size: 2.5rem; margin-bottom: 0.5rem;">⚙️</div>
93
+ <h3 style="color: var(--text-primary); font-size: 1rem; margin-bottom: 0.3rem;">Knowledge-Based</h3>
94
+ <p class="text-dim" style="font-size: 0.8rem;">Constraint, Rule, Utility-based filtering</p>
95
+ </div>
96
+ </div>
97
+ {% endcall %}
98
+ {% endblock %}
99
 
100
+ {% block scripts %}
101
+ <script>
102
+ const userData = {{ users | tojson | safe }};
 
 
 
103
 
104
+ document.addEventListener('alpine:init', () => {
105
+ Alpine.data('homeApp', () => ({
106
+ selectedUserId: localStorage.getItem('selectedUser') || null,
107
+ selectedUser: null,
108
+ showEditor: false,
109
+ editBudgetMin: 0,
110
+ editBudgetMax: 100,
111
+ savingPrefs: false,
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
+ init() {
114
+ if (this.selectedUserId) {
115
+ this.selectedUserId = parseInt(this.selectedUserId);
116
+ this.onUserChange();
117
+ }
118
+ },
119
+
120
+ onUserChange() {
121
+ const uid = this.selectedUserId ? parseInt(this.selectedUserId) : null;
122
+ if (!uid) {
123
+ this.selectedUser = null;
124
+ return;
125
+ }
126
+ this.selectedUser = userData.find(u => u.id === uid);
127
+ if (this.selectedUser) {
128
+ this.editBudgetMin = this.selectedUser.budget_min;
129
+ this.editBudgetMax = this.selectedUser.budget_max;
130
+ }
131
+ localStorage.setItem('selectedUser', uid);
132
+ },
133
 
134
+ async savePreferences() {
135
+ if (!this.selectedUser) return;
136
+ this.savingPrefs = true;
137
+ try {
138
+ const r = await fetch(`/api/user/${this.selectedUser.id}/preferences`, {
139
+ method: 'PUT',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({
142
+ budget_min: this.editBudgetMin,
143
+ budget_max: this.editBudgetMax
144
+ })
145
+ });
146
+ const data = await r.json();
147
+ if (data.success) {
148
+ this.selectedUser.budget_min = this.editBudgetMin;
149
+ this.selectedUser.budget_max = this.editBudgetMax;
150
+ toast('Preferences saved!', 'success');
151
+ this.showEditor = false;
152
+ }
153
+ } catch (err) {
154
+ toast('Error saving preferences', 'error');
155
+ } finally {
156
+ this.savingPrefs = false;
157
  }
158
+ }
159
+ }));
160
+ });
161
+ </script>
162
+ {% endblock %}
templates/macros.html ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% macro nav(active_page) %}
2
+ <nav class="nav">
3
+ <a href="/" class="nav-brand">
4
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
5
+ Taste<span>Engine</span>
6
+ </a>
7
+ <div class="nav-center">
8
+ <div class="nav-links">
9
+ <a href="/" class="{{ 'active' if active_page == 'home' }}">Home</a>
10
+ <a href="/recommend" class="{{ 'active' if active_page == 'recommend' }}">Recommend</a>
11
+ <a href="/evaluate" class="{{ 'active' if active_page == 'evaluate' }}">Evaluate</a>
12
+ </div>
13
+ </div>
14
+ <div class="nav-actions">
15
+ <button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme" aria-label="Toggle dark/light mode">
16
+ <span id="themeIcon">🌙</span>
17
+ </button>
18
+ </div>
19
+ </nav>
20
+ {% endmacro %}
21
+
22
+ {% macro glass(title, class="") %}
23
+ <div class="glass {{ class }}">
24
+ {% if title %}<h2 class="glass-header">{{ title }}</h2>{% endif %}
25
+ {{ caller() }}
26
+ </div>
27
+ {% endmacro %}
28
+
29
+ {% macro product_card(item) %}
30
+ <div class="product-card">
31
+ <div class="product-icon">{{ get_category_icon(item.category) }}</div>
32
+ <div class="product-name">{{ item.name }}</div>
33
+ <div class="product-meta">{{ item.brand }} · {{ item.subcategory }}</div>
34
+ <div class="compact-row">
35
+ <div class="product-price">${{ "%.2f"|format(item.price) }}</div>
36
+ <div class="product-rating">{{ stars(item.avg_rating) }} {{ item.avg_rating }}</div>
37
+ </div>
38
+ <div class="product-explanation">{{ item.explanation }}</div>
39
+ </div>
40
+ {% endmacro %}
41
+
42
+ {% macro tag(text, type="") %}
43
+ <span class="tag{% if type == 'brand' %} tag-brand{% endif %}">{{ text }}</span>
44
+ {% endmacro %}
45
+
46
+ {% macro spinner(text="Loading...") %}
47
+ <div class="loading">
48
+ <div class="spinner"></div>
49
+ <span>{{ text }}</span>
50
+ </div>
51
+ {% endmacro %}
52
+
53
+ {% macro empty_state(icon, title, desc) %}
54
+ <div class="empty-state">
55
+ <div class="empty-icon">{{ icon }}</div>
56
+ <div class="empty-title">{{ title }}</div>
57
+ <div class="empty-desc">{{ desc }}</div>
58
+ </div>
59
+ {% endmacro %}
60
+
61
+ {% macro tooltip(term, explanation) %}
62
+ <span class="tooltip-trigger">{{ term }}<span class="tooltip-content">{{ explanation }}</span></span>
63
+ {% endmacro %}
64
+
65
+ {% macro kpi_card(label, value, unit, class="") %}
66
+ <div class="kpi-card {{ class }}">
67
+ <div class="kpi-label">{{ label }}</div>
68
+ <div class="kpi-value">{{ value }}</div>
69
+ {% if unit %}<div class="kpi-unit">{{ unit }}</div>{% endif %}
70
+ </div>
71
+ {% endmacro %}
72
+
73
+ {% macro skeleton_cards(count=6) %}
74
+ <div class="product-grid" id="skeletonGrid">
75
+ {% for i in range(count) %}
76
+ <div class="product-card skeleton-card">
77
+ <div class="skeleton" style="width:48px;height:48px;border-radius:10px;margin-bottom:0.75rem;"></div>
78
+ <div class="skeleton skeleton-line"></div>
79
+ <div class="skeleton skeleton-line" style="width:60%;"></div>
80
+ <div class="skeleton skeleton-line" style="width:40%;"></div>
81
+ </div>
82
+ {% endfor %}
83
+ </div>
84
+ {% endmacro %}
templates/recommend.html CHANGED
@@ -1,347 +1,411 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>TasteEngine Recommendations</title>
7
- <link rel="preconnect" href="https://fonts.googleapis.com">
8
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
9
- <link rel="stylesheet" href="/static/css/style.css">
10
- </head>
11
- <body>
12
- <nav class="nav">
13
- <a href="/" class="nav-brand">Taste<span>Engine</span></a>
14
- <div class="nav-links">
15
- <a href="/">Home</a>
16
- <a href="/recommend" class="active">Recommend</a>
17
- <a href="/evaluate">Evaluate</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  </div>
19
- </nav>
20
-
21
- <div class="container">
22
- <div class="row">
23
- <div class="glass">
24
- <h2 class="glass-header">User</h2>
25
- <div class="form-group">
26
- <label class="form-label">Select User</label>
27
- <select id="userSelect" onchange="onUserChange()">
28
- <option value="">-- Choose --</option>
29
- {% for u in users %}
30
- <option value="{{ u.id }}">{{ u.name }}</option>
31
- {% endfor %}
32
- </select>
33
- </div>
34
- <div id="profileMini" class="hidden mt-1">
35
- <div class="compact-row">
36
- <div class="profile-avatar" id="miniAvatar" style="width:40px;height:40px;font-size:1rem;">A</div>
37
- <div>
38
- <div id="miniName" style="font-weight:600;color:#fff;font-size:0.9rem;"></div>
39
- <div id="miniCats" style="font-size:0.8rem;color:rgba(255,255,255,0.5);"></div>
40
- </div>
41
- </div>
42
- </div>
43
- </div>
44
 
45
- <div class="glass">
46
- <h2 class="glass-header">Number of Recommendations</h2>
47
- <div class="form-group">
48
- <input type="number" id="nRecs" value="10" min="1" max="50" style="max-width: 100px;">
49
- </div>
50
- </div>
 
 
 
 
 
 
51
  </div>
 
 
52
 
53
- <div class="glass">
54
- <h2 class="glass-header">Choose Approach & Method</h2>
55
- <div class="btn-group" id="approachGroup">
56
- <button class="btn-approach" data-approach="cf" onclick="selectApproach('cf')">
57
- <span class="icon">🤝</span>
58
- Collaborative Filtering
59
- </button>
60
- <button class="btn-approach" data-approach="content" onclick="selectApproach('content')">
61
- <span class="icon">🏷️</span>
62
- Content-Based
63
- </button>
64
- <button class="btn-approach" data-approach="knowledge" onclick="selectApproach('knowledge')">
65
- <span class="icon">⚙️</span>
66
- Knowledge-Based
67
- </button>
68
- </div>
69
 
70
- <div id="methodsContainer" class="mt-2">
71
- {% for key, approach in approaches.items() %}
72
- <div id="methods-{{ key }}" class="methods-group hidden">
73
- <div class="btn-group">
74
- {% for m in approach.methods %}
75
- <button class="btn-method" data-method="{{ m.id }}" data-approach="{{ key }}" onclick="selectMethod('{{ key }}', '{{ m.id }}')">{{ m.label }}</button>
76
- {% endfor %}
77
- </div>
78
- </div>
79
- {% endfor %}
80
- </div>
81
 
82
- <div class="mt-2">
83
- <button class="btn btn-primary" onclick="getRecommendations()">🚀 Generate Recommendations</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  </div>
85
- </div>
86
-
87
- <div id="resultsContainer" class="hidden">
88
- <div class="tabs" id="resultTabs">
89
- <button class="tab active" data-tab="tab-cards" onclick="switchTab('tab-cards')">📋 Cards</button>
90
- <button class="tab" data-tab="tab-compare" onclick="switchTab('tab-compare')">🔄 Compare Approaches</button>
 
 
91
  </div>
92
-
93
- <div id="tab-cards" class="tab-content active">
94
- <div class="glass">
95
- <h2 class="glass-header" id="resultHeader">Recommendations</h2>
96
- <div id="productGrid" class="product-grid mt-2"></div>
97
- </div>
98
  </div>
99
-
100
- <div id="tab-compare" class="tab-content">
101
- <div class="glass">
102
- <h2 class="glass-header">Compare All Approaches</h2>
103
- <p style="color:rgba(255,255,255,0.5);font-size:0.85rem;margin-bottom:1rem;">Select a user first, then click below to compare.</p>
104
- <button class="btn btn-primary" onclick="compareAll()">🔄 Compare All Approaches</button>
105
- <div id="compareResults" class="mt-2"></div>
106
- </div>
107
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  </div>
 
 
109
 
110
- <div id="loadingIndicator" class="loading hidden">
111
- <div class="spinner"></div>
112
- <span>Generating recommendations...</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  </div>
 
114
  </div>
115
-
116
- <script>
117
- const userData = {{ users | tojson | safe }};
118
- const approaches = {{ approaches | tojson | safe }};
119
- let currentApproach = 'cf';
120
- let currentMethod = 'user_based';
121
-
122
- const savedUser = localStorage.getItem('selectedUser');
123
- if (savedUser) {
124
- document.getElementById('userSelect').value = savedUser;
125
- onUserChange();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  }
127
-
128
- document.getElementById('userSelect').addEventListener('change', function() {
129
- localStorage.setItem('selectedUser', this.value);
130
- });
131
-
132
- function onUserChange() {
133
- const uid = parseInt(document.getElementById('userSelect').value);
134
- if (!uid) {
135
- document.getElementById('profileMini').classList.add('hidden');
136
- return;
137
- }
138
- const user = userData.find(u => u.id === uid);
139
- if (!user) return;
140
-
141
- document.getElementById('profileMini').classList.remove('hidden');
142
- document.getElementById('miniAvatar').textContent = user.name.charAt(0);
143
- document.getElementById('miniName').textContent = user.name;
144
- document.getElementById('miniCats').textContent = 'Categories: ' + (user.categories || []).join(', ');
145
  }
146
-
147
- function selectApproach(approach) {
148
- currentApproach = approach;
149
-
150
- document.querySelectorAll('.btn-approach').forEach(b => b.classList.remove('active'));
151
- document.querySelector(`.btn-approach[data-approach="${approach}"]`).classList.add('active');
152
-
153
- document.querySelectorAll('.methods-group').forEach(g => g.classList.add('hidden'));
154
- document.getElementById(`methods-${approach}`).classList.remove('hidden');
155
-
156
- const firstMethod = document.querySelector(`#methods-${approach} .btn-method`);
157
- if (firstMethod) {
158
- currentMethod = firstMethod.dataset.method;
159
- document.querySelectorAll('.btn-method').forEach(b => b.classList.remove('active'));
160
- firstMethod.classList.add('active');
161
- }
162
-
163
- document.getElementById('resultsContainer').classList.add('hidden');
 
 
164
  }
165
-
166
- function selectMethod(approach, method) {
167
- currentMethod = method;
168
- document.querySelectorAll(`#methods-${approach} .btn-method`).forEach(b => b.classList.remove('active'));
169
- document.querySelector(`#methods-${approach} .btn-method[data-method="${method}"]`).classList.add('active');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  }
171
 
172
- selectApproach('cf');
173
-
174
- function getRecommendations(userOverride, approachOverride, methodOverride) {
175
- const uid = parseInt(userOverride || document.getElementById('userSelect').value);
176
- const approach = approachOverride || currentApproach;
177
- const method = methodOverride || currentMethod;
178
- const n = parseInt(document.getElementById('nRecs').value) || 10;
179
-
180
- if (!uid) {
181
- alert('Please select a user first.');
182
- return;
183
- }
184
-
185
- document.getElementById('loadingIndicator').classList.remove('hidden');
186
- document.getElementById('resultsContainer').classList.add('hidden');
187
-
188
- fetch('/api/recommend', {
189
- method: 'POST',
190
- headers: { 'Content-Type': 'application/json' },
191
- body: JSON.stringify({ user_id: uid, approach: approach, method: method, n: n })
192
- })
193
- .then(r => r.json())
194
- .then(data => {
195
- document.getElementById('loadingIndicator').classList.add('hidden');
196
-
197
- if (data.error) {
198
- document.getElementById('productGrid').innerHTML = `<div class="empty-state"><div class="icon">⚠️</div><p>${data.error}</p></div>`;
199
- document.getElementById('resultsContainer').classList.remove('hidden');
200
- return;
201
- }
202
-
203
- const approachInfo = approaches[approach];
204
- const methodInfo = approachInfo.methods.find(m => m.id === method);
205
- document.getElementById('resultHeader').textContent =
206
- `Recommendations via ${approachInfo.label} — ${methodInfo ? methodInfo.label : method}`;
207
-
208
- const grid = document.getElementById('productGrid');
209
- grid.innerHTML = '';
210
-
211
- if (!data.recommendations || data.recommendations.length === 0) {
212
- grid.innerHTML = `<div class="empty-state"><div class="icon">📭</div><p>No recommendations found. Try a different approach.</p></div>`;
213
- document.getElementById('resultsContainer').classList.remove('hidden');
214
- return;
215
- }
216
-
217
- data.recommendations.forEach(item => {
218
- const card = document.createElement('div');
219
- card.className = 'product-card';
220
-
221
- const stars = '★'.repeat(Math.floor(item.avg_rating)) + '☆'.repeat(5 - Math.floor(item.avg_rating));
222
-
223
- card.innerHTML = `
224
- <div class="product-icon">${getCategoryIcon(item.category)}</div>
225
- <div class="product-name">${item.name}</div>
226
- <div class="product-meta">${item.brand} · ${item.subcategory}</div>
227
- <div class="compact-row">
228
- <div class="product-price">$${item.price.toFixed(2)}</div>
229
- <div class="product-rating">${stars} ${item.avg_rating}</div>
230
- </div>
231
- <div class="product-explanation">💡 ${item.explanation}</div>
232
- `;
233
- grid.appendChild(card);
234
- });
235
-
236
- document.getElementById('resultsContainer').classList.remove('hidden');
237
- switchTab('tab-cards');
238
  })
239
- .catch(err => {
240
- document.getElementById('loadingIndicator').classList.add('hidden');
241
- document.getElementById('productGrid').innerHTML = `<div class="empty-state"><div class="icon">❌</div><p>Error: ${err.message}</p></div>`;
242
- document.getElementById('resultsContainer').classList.remove('hidden');
243
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  }
 
245
 
246
- function compareAll() {
247
- const uid = parseInt(document.getElementById('userSelect').value);
248
- if (!uid) {
249
- alert('Please select a user first.');
250
- return;
251
- }
252
-
253
- document.getElementById('loadingIndicator').classList.remove('hidden');
254
- const container = document.getElementById('compareResults');
255
- container.innerHTML = '';
256
-
257
- const cfMethods = approaches.cf.methods.map(m => m.id);
258
- const promises = [];
259
-
260
- cfMethods.forEach(m => {
261
- promises.push(
262
- fetch('/api/recommend', {
263
- method: 'POST',
264
- headers: { 'Content-Type': 'application/json' },
265
- body: JSON.stringify({ user_id: uid, approach: 'cf', method: m, n: 5 })
266
- }).then(r => r.json()).then(data => ({ approach: 'cf', method: m, data }))
267
- );
268
- });
269
-
270
- Object.keys(approaches).forEach(app => {
271
- const methods = approaches[app].methods;
272
- methods.forEach(m => {
273
- if (app !== 'cf') {
274
- promises.push(
275
- fetch('/api/recommend', {
276
- method: 'POST',
277
- headers: { 'Content-Type': 'application/json' },
278
- body: JSON.stringify({ user_id: uid, approach: app, method: m.id, n: 5 })
279
- }).then(r => r.json()).then(data => ({ approach: app, method: m.id, data }))
280
- );
281
- }
282
- });
283
- });
284
-
285
- Promise.all(promises).then(results => {
286
- document.getElementById('loadingIndicator').classList.add('hidden');
287
- document.getElementById('resultsContainer').classList.remove('hidden');
288
- switchTab('tab-compare');
289
-
290
- container.innerHTML = '<div class="compare-view"></div>';
291
- const compareGrid = container.querySelector('.compare-view');
292
-
293
- results.forEach(result => {
294
- const appInfo = approaches[result.approach];
295
- const methodInfo = appInfo.methods.find(m => m.id === result.method);
296
- const col = document.createElement('div');
297
- col.className = 'compare-column';
298
- col.innerHTML = `<h3>${appInfo.icon} ${methodInfo ? methodInfo.label : result.method}</h3>`;
299
-
300
- if (result.data.error) {
301
- col.innerHTML += `<p style="color:rgba(255,255,255,0.4);font-size:0.8rem;">${result.data.error}</p>`;
302
- } else if (!result.data.recommendations || result.data.recommendations.length === 0) {
303
- col.innerHTML += `<p style="color:rgba(255,255,255,0.4);font-size:0.8rem;">No results</p>`;
304
- } else {
305
- const list = document.createElement('div');
306
- result.data.recommendations.forEach((item, i) => {
307
- const div = document.createElement('div');
308
- div.style.cssText = 'padding:0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.04); font-size:0.8rem;';
309
- div.innerHTML = `
310
- <div style="font-weight:500;color:#fff;">${i+1}. ${item.name}</div>
311
- <div style="color:rgba(255,255,255,0.4);">$${item.price.toFixed(2)} · ${item.brand}</div>
312
- `;
313
- list.appendChild(div);
314
- });
315
- col.appendChild(list);
316
- }
317
- compareGrid.appendChild(col);
318
- });
319
- }).catch(err => {
320
- document.getElementById('loadingIndicator').classList.add('hidden');
321
- container.innerHTML = `<div class="empty-state"><div class="icon">❌</div><p>Error: ${err.message}</p></div>`;
322
- });
323
  }
324
 
325
- function switchTab(tabId) {
326
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
327
- document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
328
- document.querySelector(`.tab[data-tab="${tabId}"]`).classList.add('active');
329
- document.getElementById(tabId).classList.add('active');
330
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
 
332
- function getCategoryIcon(category) {
333
- const icons = {
334
- 'Electronics': '💻',
335
- 'Clothing': '👕',
336
- 'Home & Kitchen': '🏠',
337
- 'Books': '📚',
338
- 'Sports': '⚽',
339
- 'Beauty': '💄',
340
- 'Toys': '🧸',
341
- 'Automotive': '🚗'
342
- };
343
- return icons[category] || '📦';
 
 
 
 
 
344
  }
345
- </script>
346
- </body>
347
- </html>
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}TasteEngine — Recommendations{% endblock %}
3
+
4
+ {% block breadcrumb %}
5
+ <ul class="breadcrumb">
6
+ <li><a href="/">Home</a><span class="sep">/</span></li>
7
+ <li class="current">Recommend</li>
8
+ </ul>
9
+ {% endblock %}
10
+
11
+ {% block content %}
12
+ {% from "macros.html" import glass, spinner, skeleton_cards %}
13
+
14
+ <div x-data="recommendApp()">
15
+ <div class="row">
16
+ {% call glass("User") %}
17
+ <div class="form-group">
18
+ <label class="form-label" for="userSelect">Select User</label>
19
+ <select id="userSelect" x-model="selectedUserId" @change="onUserChange">
20
+ <option value="">-- Choose --</option>
21
+ {% for u in users %}
22
+ <option value="{{ u.id }}">{{ u.name }}</option>
23
+ {% endfor %}
24
+ </select>
25
+ </div>
26
+ <div class="mt-1" x-show="selectedUser" x-cloak>
27
+ <div class="compact-row">
28
+ <div class="profile-avatar" style="width:40px;height:40px;font-size:1rem;" x-text="selectedUser?.name?.charAt(0) || '?'"></div>
29
+ <div>
30
+ <div style="font-weight:600;color:var(--text-primary);font-size:0.9rem;" x-text="selectedUser?.name"></div>
31
+ <div style="font-size:0.8rem;color:var(--text-muted);" x-text="'Categories: ' + (selectedUser?.categories?.join(', ') || 'none')"></div>
32
+ </div>
33
  </div>
34
+ </div>
35
+ {% endcall %}
36
+
37
+ {% call glass("Number of Recommendations") %}
38
+ <div class="form-group">
39
+ <input type="number" id="nRecs" value="10" min="1" max="50" style="max-width: 100px;" x-model.number="nRecs">
40
+ </div>
41
+ {% endcall %}
42
+ </div>
43
+
44
+ {% call glass("Choose Approach & Method") %}
45
+ <div class="btn-group" id="approachGroup">
46
+ <template x-for="(app, key) in approaches" :key="key">
47
+ <button class="btn-approach"
48
+ :class="{ active: currentApproach === key }"
49
+ :data-approach="key"
50
+ @click="selectApproach(key)">
51
+ <span class="icon" x-text="app.icon"></span>
52
+ <span x-text="app.label"></span>
53
+ </button>
54
+ </template>
55
+ </div>
 
 
 
56
 
57
+ <div class="mt-2">
58
+ <template x-for="(app, key) in approaches" :key="key">
59
+ <div x-show="currentApproach === key" x-transition.duration.200ms>
60
+ <div class="btn-group">
61
+ <template x-for="m in app.methods" :key="m.id">
62
+ <button class="btn-method"
63
+ :class="{ active: currentMethod === m.id }"
64
+ @click="currentMethod = m.id"
65
+ x-text="m.label">
66
+ </button>
67
+ </template>
68
+ </div>
69
  </div>
70
+ </template>
71
+ </div>
72
 
73
+ <div class="mt-2">
74
+ <button class="btn btn-primary" @click="getRecommendations()" :disabled="loading || !selectedUserId">
75
+ <span x-show="!loading">Get Recommendations</span>
76
+ <span x-show="loading">Generating...</span>
77
+ </button>
78
+ </div>
79
+ {% endcall %}
 
 
 
 
 
 
 
 
 
80
 
81
+ <div x-show="showResults" x-cloak x-transition.duration.300ms>
82
+ <div class="tabs">
83
+ <button class="tab" :class="{ active: activeTab === 'cards' }" @click="activeTab = 'cards'">Cards</button>
84
+ <button class="tab" :class="{ active: activeTab === 'compare' }" @click="activeTab = 'compare'">Compare Approaches</button>
85
+ </div>
 
 
 
 
 
 
86
 
87
+ <div x-show="activeTab === 'cards'" x-transition>
88
+ {% call glass("") %}
89
+ <h2 class="glass-header" x-text="resultTitle"></h2>
90
+ <div class="mt-2">
91
+ <div x-show="!results && loading" x-transition>
92
+ {{ skeleton_cards(6) }}
93
+ </div>
94
+ <div x-show="results && results.length === 0" x-transition>
95
+ {% from "macros.html" import empty_state %}
96
+ {{ empty_state('📭', 'No recommendations', 'Try a different approach or method.') }}
97
+ </div>
98
+
99
+ <div class="filter-bar" x-show="results && results.length > 0" x-cloak>
100
+ <div class="form-group">
101
+ <label class="form-label">Category</label>
102
+ <select x-model="filterCategory">
103
+ <option value="">All Categories</option>
104
+ <template x-for="cat in availableCategories" :key="cat">
105
+ <option :value="cat" x-text="cat"></option>
106
+ </template>
107
+ </select>
108
  </div>
109
+ <div class="form-group">
110
+ <label class="form-label">Brand</label>
111
+ <select x-model="filterBrand">
112
+ <option value="">All Brands</option>
113
+ <template x-for="b in availableBrands" :key="b">
114
+ <option :value="b" x-text="b"></option>
115
+ </template>
116
+ </select>
117
  </div>
118
+ <div class="form-group">
119
+ <label class="form-label">Max Price</label>
120
+ <input type="number" x-model.number="filterPriceMax" placeholder="Any" min="0" style="min-width:100px;">
 
 
 
121
  </div>
122
+ <div class="form-group" style="align-self:end;">
123
+ <button class="btn btn-ghost btn-sm" @click="filterCategory=''; filterBrand=''; filterPriceMax=null">
124
+ Clear Filters
125
+ </button>
 
 
 
 
126
  </div>
127
+ </div>
128
+
129
+ <div class="product-grid" x-show="results && results.length > 0" x-transition>
130
+ <template x-for="(item, idx) in filteredResults" :key="item.id">
131
+ <div class="product-card stagger"
132
+ :data-category="item.category"
133
+ :style="{ animationDelay: (idx * 60) + 'ms' }"
134
+ @mouseenter="item.showTooltip = true"
135
+ @mouseleave="item.showTooltip = false">
136
+ <div class="product-icon" x-text="getCategoryIcon(item.category)"></div>
137
+ <div class="product-name" x-text="item.name"></div>
138
+ <div class="product-meta" x-text="item.brand + ' · ' + item.subcategory"></div>
139
+ <div class="compact-row flex-between">
140
+ <div class="product-price" x-text="'$' + item.price.toFixed(2)"></div>
141
+ <div class="product-rating">
142
+ <span x-text="stars(item.avg_rating)"></span>
143
+ <span x-text="item.avg_rating"></span>
144
+ </div>
145
+ </div>
146
+ <div class="product-score-bar" x-show="item.score">
147
+ <div class="product-score-bar-fill" :style="{ width: (item.score * 100) + '%' }"></div>
148
+ </div>
149
+ <div class="product-explanation" x-text="item.explanation"></div>
150
+ </div>
151
+ </template>
152
+ </div>
153
  </div>
154
+ {% endcall %}
155
+ </div>
156
 
157
+ <div x-show="activeTab === 'compare'" x-transition>
158
+ {% call glass("Compare All Approaches") %}
159
+ <p class="text-muted" style="font-size:0.85rem;margin-bottom:1rem;">
160
+ Compare all methods across Collaborative Filtering, Content-Based, and Knowledge-Based.
161
+ </p>
162
+ <button class="btn btn-primary" @click="compareAll()" :disabled="loading || !selectedUserId">
163
+ <span x-show="!loading">Compare All Approaches</span>
164
+ <span x-show="loading">Loading...</span>
165
+ </button>
166
+ <div class="mt-2" x-show="compareResults">
167
+ <div class="compare-view synced-scroll">
168
+ <template x-for="col in compareResults" :key="col.method || col.label">
169
+ <div class="compare-column" @scroll="syncScroll($el)">
170
+ <h3 x-text="col.label"></h3>
171
+ <template x-if="col.error">
172
+ <p class="text-dim" style="font-size:0.8rem;" x-text="col.error"></p>
173
+ </template>
174
+ <template x-if="!col.error && (!col.items || col.items.length === 0)">
175
+ <p class="text-dim" style="font-size:0.8rem;">No results</p>
176
+ </template>
177
+ <template x-for="(item, i) in col.items" :key="i">
178
+ <div class="compare-item">
179
+ <div class="compare-item-name" x-text="(i+1) + '. ' + item.name"></div>
180
+ <div class="compare-item-meta" x-text="'$' + item.price.toFixed(2) + ' · ' + item.brand"></div>
181
+ </div>
182
+ </template>
183
+ </div>
184
+ </template>
185
+ </div>
186
  </div>
187
+ {% endcall %}
188
  </div>
189
+ </div>
190
+ </div>
191
+
192
+ <div class="kbd-hint" id="kbdHint">
193
+ <kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd> Select approach ·
194
+ <kbd>R</kbd> Recommend ·
195
+ <kbd>E</kbd> Evaluate ·
196
+ <kbd>H</kbd> Home
197
+ </div>
198
+
199
+ <script>
200
+ setTimeout(() => document.getElementById('kbdHint')?.classList.add('visible'), 2000);
201
+ setTimeout(() => document.getElementById('kbdHint')?.classList.remove('visible'), 8000);
202
+ document.addEventListener('keydown', () => {
203
+ const h = document.getElementById('kbdHint');
204
+ if (h) h.classList.remove('visible');
205
+ });
206
+ </script>
207
+ {% endblock %}
208
+
209
+ {% block scripts %}
210
+ <script>
211
+ const userData = {{ users | tojson | safe }};
212
+ const approaches = {{ approaches | tojson | safe }};
213
+
214
+ document.addEventListener('alpine:init', () => {
215
+ Alpine.data('recommendApp', () => ({
216
+ approaches: approaches,
217
+ selectedUserId: null,
218
+ selectedUser: null,
219
+ currentApproach: 'cf',
220
+ currentMethod: 'user_based',
221
+ loading: false,
222
+ results: null,
223
+ resultTitle: '',
224
+ compareResults: null,
225
+ activeTab: 'cards',
226
+ nRecs: 10,
227
+ showResults: false,
228
+ filterCategory: '',
229
+ filterBrand: '',
230
+ filterPriceMax: null,
231
+
232
+ get availableCategories() {
233
+ return [...new Set((this.results || []).map(i => i.category))];
234
+ },
235
+
236
+ get availableBrands() {
237
+ return [...new Set((this.results || []).map(i => i.brand))];
238
+ },
239
+
240
+ get filteredResults() {
241
+ let items = this.results || [];
242
+ if (this.filterCategory) items = items.filter(i => i.category === this.filterCategory);
243
+ if (this.filterBrand) items = items.filter(i => i.brand === this.filterBrand);
244
+ if (this.filterPriceMax) items = items.filter(i => i.price <= this.filterPriceMax);
245
+ return items;
246
+ },
247
+
248
+ init() {
249
+ const params = new URLSearchParams(window.location.search);
250
+ const urlUser = params.get('user');
251
+ const urlApproach = params.get('approach');
252
+ const urlMethod = params.get('method');
253
+ const saved = urlUser || localStorage.getItem('selectedUser');
254
+ if (saved) {
255
+ this.selectedUserId = parseInt(saved);
256
+ this.onUserChange();
257
  }
258
+ if (urlApproach && this.approaches[urlApproach]) {
259
+ this.currentApproach = urlApproach;
260
+ if (urlMethod) this.currentMethod = urlMethod;
261
+ else if (this.approaches[urlApproach].methods.length > 0) {
262
+ this.currentMethod = this.approaches[urlApproach].methods[0].id;
263
+ }
 
 
 
 
 
 
 
 
 
 
 
 
264
  }
265
+ setTimeout(() => {
266
+ if (urlUser) this.getRecommendations();
267
+ }, 300);
268
+ },
269
+
270
+ updateUrl() {
271
+ const params = new URLSearchParams();
272
+ if (this.selectedUserId) params.set('user', this.selectedUserId);
273
+ if (this.currentApproach) params.set('approach', this.currentApproach);
274
+ if (this.currentMethod) params.set('method', this.currentMethod);
275
+ const qs = params.toString();
276
+ const url = window.location.pathname + (qs ? '?' + qs : '');
277
+ window.history.replaceState({}, '', url);
278
+ },
279
+
280
+ onUserChange() {
281
+ const uid = this.selectedUserId;
282
+ if (!uid) {
283
+ this.selectedUser = null;
284
+ return;
285
  }
286
+ this.selectedUser = userData.find(u => u.id === uid);
287
+ localStorage.setItem('selectedUser', uid);
288
+ this.results = null;
289
+ this.compareResults = null;
290
+ this.updateUrl();
291
+ },
292
+
293
+ selectApproach(key) {
294
+ this.currentApproach = key;
295
+ const methods = this.approaches[key].methods;
296
+ if (methods.length > 0) {
297
+ this.currentMethod = methods[0].id;
298
+ }
299
+ this.results = null;
300
+ this.compareResults = null;
301
+ this.updateUrl();
302
+ },
303
+
304
+ async getRecommendations() {
305
+ if (!this.selectedUserId) {
306
+ toast('Please select a user first.', 'warning');
307
+ return;
308
  }
309
 
310
+ this.loading = true;
311
+ this.results = null;
312
+ this.compareResults = null;
313
+
314
+ try {
315
+ const r = await fetch('/api/recommend', {
316
+ method: 'POST',
317
+ headers: { 'Content-Type': 'application/json' },
318
+ body: JSON.stringify({
319
+ user_id: this.selectedUserId,
320
+ approach: this.currentApproach,
321
+ method: this.currentMethod,
322
+ n: this.nRecs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  })
324
+ });
325
+ const data = await r.json();
326
+
327
+ if (data.error) {
328
+ toast(data.error, 'error');
329
+ this.results = [];
330
+ return;
331
+ }
332
+
333
+ const appInfo = this.approaches[this.currentApproach];
334
+ const methodInfo = appInfo.methods.find(m => m.id === this.currentMethod);
335
+ this.resultTitle = `Recommendations via ${appInfo.label} — ${methodInfo ? methodInfo.label : this.currentMethod}`;
336
+
337
+ this.results = data.recommendations || [];
338
+ this.activeTab = 'cards';
339
+ this.showResults = true;
340
+ this.updateUrl();
341
+
342
+ if (this.results.length > 0) {
343
+ toast(`Found ${this.results.length} recommendations`, 'success');
344
+ }
345
+ } catch (err) {
346
+ toast('Error: ' + err.message, 'error');
347
+ this.results = [];
348
+ } finally {
349
+ this.loading = false;
350
  }
351
+ },
352
 
353
+ async compareAll() {
354
+ if (!this.selectedUserId) {
355
+ toast('Please select a user first.', 'warning');
356
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  }
358
 
359
+ this.loading = true;
360
+ this.compareResults = null;
361
+
362
+ const promises = [];
363
+ const totalMethods = Object.values(this.approaches).reduce((sum, a) => sum + a.methods.length, 0);
364
+ Object.keys(this.approaches).forEach(appKey => {
365
+ this.approaches[appKey].methods.forEach(m => {
366
+ promises.push(
367
+ fetch('/api/recommend', {
368
+ method: 'POST',
369
+ headers: { 'Content-Type': 'application/json' },
370
+ body: JSON.stringify({ user_id: this.selectedUserId, approach: appKey, method: m.id, n: 5 })
371
+ })
372
+ .then(r => r.json())
373
+ .then(data => ({
374
+ approach: appKey,
375
+ method: m.id,
376
+ label: this.approaches[appKey].icon + ' ' + m.label,
377
+ data
378
+ }))
379
+ .catch(err => ({
380
+ approach: appKey,
381
+ method: m.id,
382
+ label: this.approaches[appKey].icon + ' ' + m.label,
383
+ data: { error: err.message, recommendations: [] }
384
+ }))
385
+ );
386
+ });
387
+ });
388
 
389
+ try {
390
+ const results = await Promise.all(promises);
391
+ this.compareResults = results.map(r => ({
392
+ approach: r.approach,
393
+ method: r.method,
394
+ label: r.label,
395
+ error: r.data.error,
396
+ items: r.data.recommendations || []
397
+ }));
398
+ this.activeTab = 'compare';
399
+ this.showResults = true;
400
+ const loaded = results.filter(r => !r.data.error && r.data.recommendations?.length).length;
401
+ toast(`Loaded ${loaded} of ${totalMethods} methods`, loaded === totalMethods ? 'success' : 'warning');
402
+ } catch (err) {
403
+ toast('Error: ' + err.message, 'error');
404
+ } finally {
405
+ this.loading = false;
406
  }
407
+ },
408
+ }));
409
+ });
410
+ </script>
411
+ {% endblock %}
utils/__pycache__/__init__.cpython-310.pyc CHANGED
Binary files a/utils/__pycache__/__init__.cpython-310.pyc and b/utils/__pycache__/__init__.cpython-310.pyc differ
 
utils/__pycache__/helpers.cpython-310.pyc CHANGED
Binary files a/utils/__pycache__/helpers.cpython-310.pyc and b/utils/__pycache__/helpers.cpython-310.pyc differ
 
utils/__pycache__/similarity.cpython-310.pyc CHANGED
Binary files a/utils/__pycache__/similarity.cpython-310.pyc and b/utils/__pycache__/similarity.cpython-310.pyc differ