subhan971 commited on
Commit
7c8ccaa
·
verified ·
1 Parent(s): 5e801f9

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +103 -330
app.py CHANGED
@@ -1,20 +1,11 @@
1
- """
2
- Enhanced FastAPI: Matchmaking Recommendation API
3
- Features:
4
- - /health: Server status
5
- - /recommend/{user_id}: Get top N matches with compatibility scores
6
- - /feedback: Record user likes/rejects
7
- - Cold‑start capable: Any user can get recommendations
8
- - CORS enabled for frontend integration
9
  """
10
 
11
  # ----------------------------------------------------------------------
12
- # Imports (added pickle‑safety utilities)
13
  # ----------------------------------------------------------------------
14
  import os
15
- import pickle
16
- import pickletools # inspect pickle op‑codes safely
17
- import re # glob → regex conversion
18
  import httpx
19
  import numpy as np
20
  from datetime import datetime
@@ -22,113 +13,40 @@ from fastapi import FastAPI, HTTPException
22
  from fastapi.middleware.cors import CORSMiddleware
23
  from sklearn.metrics.pairwise import cosine_similarity
24
  import logging
25
- import json
26
- import base64
27
-
28
- # New typing import (required by the safety helper)
29
- from typing import Iterable, List
30
 
31
  # ----------------------------------------------------------------------
32
- # Logging
33
  # ----------------------------------------------------------------------
34
  logging.basicConfig(
35
  level=logging.INFO,
36
  format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
37
  )
38
- logger = logging.getLogger(__name__)
39
-
40
- # ----------------------------------------------------------------------
41
- # Pickle‑safety helper
42
- # ----------------------------------------------------------------------
43
- class PickleSafetyError(RuntimeError):
44
- """Raised when a pickle contains a disallowed import."""
45
- pass
46
-
47
-
48
- def _glob_to_regex(pattern: str) -> re.Pattern:
49
- esc = re.escape(pattern)
50
- esc = esc.replace(r"\*\*", r".*") # recursive *
51
- esc = esc.replace(r"\*", r"[^.]+") # single‑level *
52
- return re.compile(f"^{esc}$")
53
-
54
-
55
- def _build_allowlist(pats: Iterable[str]) -> List[re.Pattern]:
56
- return [_glob_to_regex(p) for p in pats]
57
-
58
-
59
- def _is_allowed(name: str, regexes: List[re.Pattern]) -> bool:
60
- return any(r.match(name) for r in regexes)
61
-
62
-
63
- def _extract_imports(pickle_bytes: bytes) -> List[str]:
64
- imports: List[str] = []
65
- for op, arg, _ in pickletools.genops(pickle_bytes):
66
- if op.name in ("GLOBAL", "STACK_GLOBAL"):
67
- module, obj = arg
68
- imports.append(f"{module}.{obj}")
69
- return imports
70
-
71
-
72
- def safe_pickle_load(
73
- fileobj,
74
- *,
75
- whitelist: Iterable[str] | None = None,
76
- raise_on_error: bool = True,
77
- ) -> object:
78
- """
79
- Load a pickle **only after** verifying that every import it contains
80
- matches the supplied ``whitelist`` (glob‑style patterns).
81
- """
82
- raw = fileobj.read()
83
- imports = _extract_imports(raw)
84
-
85
- if whitelist is None:
86
- whitelist = [
87
- "builtins.object",
88
- "builtins.list",
89
- "builtins.dict",
90
- "builtins.tuple",
91
- "builtins.set",
92
- "builtins.int",
93
- "builtins.float",
94
- "builtins.str",
95
- "builtins.bytes",
96
- ]
97
-
98
- allow_regexes = _build_allowlist(whitelist)
99
- bad = [imp for imp in imports if not _is_allowed(imp, allow_regexes)]
100
- if bad:
101
- msg = ["Pickle rejected – unsafe imports detected:"] + [
102
- f" • {i}" for i in bad
103
- ]
104
- full_msg = "\n".join(msg)
105
- if raise_on_error:
106
- raise PickleSafetyError(full_msg)
107
- logger.warning(full_msg)
108
- return None
109
-
110
- return pickle.loads(raw)
111
 
 
112
 
113
  # ----------------------------------------------------------------------
114
- # Config
115
  # ----------------------------------------------------------------------
116
  SUPABASE_URL = os.getenv(
117
- "SUPABASE_URL", "https://nquhiryqtbrtpauuxmsc.supabase.co"
 
118
  )
 
119
  SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY", "")
120
  HEADERS = {"apikey": SERVICE_KEY, "Content-Type": "application/json"}
 
121
  MODEL_PATH = os.getenv("MODEL_PATH", "models/recommendation_model.pkl")
122
 
 
 
 
123
  app = FastAPI(
124
  title="Matchmaking Recommendation API",
125
  version="2.0.0",
126
  description="Smart matchmaking using ML-based compatibility scoring",
127
  )
128
 
129
- # ----------------------------------------------------------------------
130
- # CORS middleware
131
- # ----------------------------------------------------------------------
132
  app.add_middleware(
133
  CORSMiddleware,
134
  allow_origins=["*"],
@@ -138,107 +56,54 @@ app.add_middleware(
138
  )
139
 
140
  # ----------------------------------------------------------------------
141
- # Global model holder
142
  # ----------------------------------------------------------------------
143
  model = None
144
  model_loaded_at = None
145
 
146
 
147
- def load_model() -> bool:
148
- """Load the trained model from disk, handling dict pickles, JSON strings, or base64‑encoded JSON."""
 
 
149
  global model, model_loaded_at
150
 
151
  if not os.path.exists(MODEL_PATH):
152
- logger.error(f"Model file not found at {MODEL_PATH}")
153
  raise FileNotFoundError(f"Model not found at {MODEL_PATH}")
154
 
155
- # ------------------------------------------------------------------
156
- # Whitelist – edit only if you need additional packages
157
- # ------------------------------------------------------------------
158
- whitelist = [
159
- "numpy.*",
160
- "scipy.*",
161
- "pandas.*",
162
- "sklearn.*",
163
- "torch.*", # kept for completeness – no torch needed at runtime
164
- "builtins.*",
165
- ]
166
-
167
- # --------------------------------------------------------------
168
- # 1️⃣ Try the safe‑pickle loader first
169
- # --------------------------------------------------------------
170
  try:
171
- with open(MODEL_PATH, "rb") as f:
172
- raw_obj = safe_pickle_load(f, whitelist=whitelist)
173
- except PickleSafetyError as pse:
174
- logger.error(f"✗ Unsafe pickle rejected: {pse}")
175
- raise
176
- except Exception as e:
177
- logger.error(f"✗ Error during safe‑pickle load: {e}")
178
- raise
179
 
180
- # --------------------------------------------------------------
181
- # 2️⃣ If we got a dict, we’re done.
182
- # --------------------------------------------------------------
183
- if isinstance(raw_obj, dict):
184
- model = raw_obj
185
- logger.info("Model loaded via safe_pickle_load (dictionary).")
186
- else:
187
- # --------------------------------------------------------------
188
- # 3️⃣ Not a dict – try to interpret the string as JSON.
189
- # --------------------------------------------------------------
190
- if not isinstance(raw_obj, str):
191
- raise RuntimeError(
192
- "Model file loaded to a non‑dict, non‑string object; cannot proceed."
193
- )
194
 
195
- stripped = raw_obj.strip()
196
- # ---- try plain JSON -------------------------------------------------
197
- try:
198
- model = json.loads(stripped)
199
- logger.info("Model string interpreted as plain JSON.")
200
- except json.JSONDecodeError:
201
- # ---- try base64‑encoded JSON ------------------------------------
202
- try:
203
- decoded = base64.b64decode(stripped).decode("utf-8")
204
- model = json.loads(decoded)
205
- logger.info(
206
- "Model string was base64‑encoded JSON – successfully decoded."
207
- )
208
- except Exception as decode_err:
209
- logger.error(
210
- f"Failed to decode model string as JSON or base64‑JSON: {decode_err}"
211
- )
212
- raise RuntimeError(
213
- "Model file is a string but not valid JSON nor base64‑encoded JSON."
214
- ) from decode_err
215
-
216
- # --------------------------------------------------------------
217
- # 4️⃣ Sanity‑check required keys
218
- # --------------------------------------------------------------
219
- required_keys = {"n_profiles", "trained_at", "feature_matrix"}
220
- missing = required_keys - set(model.keys())
221
- if missing:
222
- raise ValueError(f"Loaded model is missing expected keys: {missing}")
223
-
224
- model_loaded_at = datetime.now()
225
- logger.info(
226
- f"✓ Model loaded safely: {model['n_profiles']} profiles, trained at {model['trained_at']}"
227
- )
228
- return True
229
 
230
 
 
 
 
231
  @app.on_event("startup")
232
  async def startup():
233
- """Load model on startup (fails fast if the pickle is unsafe)."""
234
- try:
235
- load_model()
236
- except Exception as e:
237
- logger.error(f"[STARTUP ERROR] {e}")
238
 
239
 
240
  # ----------------------------------------------------------------------
241
- # Helper utilities (unchanged)
242
  # ----------------------------------------------------------------------
243
  def safe_str(val):
244
  if val is None:
@@ -249,11 +114,8 @@ def safe_str(val):
249
 
250
 
251
  def preprocess_user(user_data):
252
- try:
253
- age = int(user_data.get("age") or 25)
254
- age = max(18, min(100, age))
255
- except Exception:
256
- age = 25
257
 
258
  cat_cols = [
259
  "religion",
@@ -263,9 +125,8 @@ def preprocess_user(user_data):
263
  "maslak",
264
  "region_caste",
265
  ]
266
- cat_vals = [
267
- safe_str(user_data.get(col)) or "unknown" for col in cat_cols
268
- ]
269
 
270
  text_fields = {
271
  "hobbies": safe_str(user_data.get("hobbies")),
@@ -274,20 +135,20 @@ def preprocess_user(user_data):
274
  user_data.get("preferred_partner_criteria")
275
  ),
276
  }
 
277
  return age, cat_vals, text_fields
278
 
279
 
280
  def encode_user(age, cat_vals, text_fields, le_dict, scaler, tfidf_dict):
281
  features = []
282
 
283
- # 1. Scale age
284
  try:
285
- age_scaled = scaler.transform([[float(age)]])[0]
286
- features.extend(age_scaled)
287
- except Exception:
288
- features.extend([0.5])
289
 
290
- # 2. Encode categoricals
291
  cat_cols = [
292
  "religion",
293
  "marital_status",
@@ -296,45 +157,50 @@ def encode_user(age, cat_vals, text_fields, le_dict, scaler, tfidf_dict):
296
  "maslak",
297
  "region_caste",
298
  ]
 
299
  for col, val in zip(cat_cols, cat_vals):
300
  le = le_dict[col]
 
301
  if val not in le.classes_:
302
- if "unknown" in le.classes_:
303
- val = "unknown"
304
- else:
305
- val = le.classes_[0]
306
  try:
307
- encoded = le.transform([val])[0]
308
- features.append(float(encoded))
309
- except Exception:
310
  features.append(0.0)
311
 
312
- # 3. TFIDF for text fields
313
- text_cols = ["hobbies", "personality_traits", "preferred_partner_criteria"]
 
 
 
 
 
314
  for col in text_cols:
315
  try:
316
- tfidf = tfidf_dict[col]
317
- vec = tfidf.transform([text_fields[col]]).toarray()[0]
318
  features.extend(vec)
319
- except Exception:
320
  features.extend([0.0] * 20)
321
 
322
  return np.array(features).reshape(1, -1)
323
 
324
 
325
  # ----------------------------------------------------------------------
326
- # API endpoints (unchanged)
327
  # ----------------------------------------------------------------------
 
 
 
 
 
328
  @app.get("/health")
329
  async def health():
330
  return {
331
- "status": "running",
332
  "model_loaded": model is not None,
333
- "model_profiles": model["n_profiles"] if model else 0,
334
- "model_trained_at": model["trained_at"] if model else None,
335
- "api_loaded_at": model_loaded_at.isoformat()
336
- if model_loaded_at
337
- else None,
338
  }
339
 
340
 
@@ -342,50 +208,33 @@ async def health():
342
  async def stats():
343
  if not model:
344
  raise HTTPException(503, "Model not loaded")
 
345
  return {
346
  "total_profiles": model["n_profiles"],
347
  "feature_dimensions": model["feature_matrix"].shape[1],
348
- "model_version": model["version"],
349
  "trained_at": model["trained_at"],
350
- "categorical_features": len(
351
- ["religion", "marital_status", "qualification", "country", "maslak", "region_caste"]
352
- ),
353
- "text_features": len(
354
- ["hobbies", "personality_traits", "preferred_partner_criteria"]
355
- ),
356
- "numeric_features": 1,
357
  }
358
 
359
 
360
  @app.get("/recommend/{user_id}")
361
- async def recommend(user_id: str, top_n: int = 10, min_score: float = 0.3):
362
  if not model:
363
  raise HTTPException(503, "Model not loaded")
364
 
365
- top_n = min(max(1, top_n), 50)
366
- min_score = max(0.0, min(1.0, min_score))
367
-
368
  try:
369
- logger.info(f"Fetching user {user_id}")
370
  resp = httpx.get(
371
  f"{SUPABASE_URL}/rest/v1/profiles",
372
  headers=HEADERS,
373
  params={"id": f"eq.{user_id}", "select": "*"},
374
- timeout=10,
375
  )
 
376
  if resp.status_code != 200:
377
  raise HTTPException(404, "User not found")
378
- users = resp.json()
379
- if not users:
380
- raise HTTPException(404, "User not found")
381
- user = users[0]
382
 
383
- user_gender = safe_str(user.get("gender"))
384
- if user_gender not in ("male", "female"):
385
- raise HTTPException(400, "User gender must be 'male' or 'female'")
386
- opposite_gender = "female" if user_gender == "male" else "male"
387
 
388
  age, cat_vals, text_fields = preprocess_user(user)
 
389
  user_vec = encode_user(
390
  age,
391
  cat_vals,
@@ -395,116 +244,40 @@ async def recommend(user_id: str, top_n: int = 10, min_score: float = 0.3):
395
  model["tfidf_vectorizers"],
396
  )
397
 
398
- candidates = []
399
- for i, p in enumerate(model["profile_data"]):
400
- if (
401
- p.get("gender") == opposite_gender
402
- and p.get("is_active")
403
- and p.get("id") != user_id
404
- ):
405
- candidates.append((i, p))
406
 
407
  if not candidates:
408
  return {"matches": []}
409
 
410
- cand_indices = [c[0] for c in candidates]
411
- cand_matrix = model["feature_matrix"][cand_indices]
 
 
412
 
413
- sims = cosine_similarity(user_vec, cand_matrix)[0]
414
 
415
- top_positions = np.argsort(sims)[::-1][:top_n]
416
- matches = []
417
- for pos in top_positions:
418
- score = float(sims[pos])
419
- if score < min_score:
420
- continue
421
- candidate = candidates[pos][1]
422
- matches.append(
423
  {
424
- "user_id": candidate["id"],
425
- "name": candidate.get("full_name", "Unknown"),
426
- "age": candidate.get("age", 0),
427
- "city": candidate.get("city", ""),
428
- "gender": candidate.get("gender", ""),
429
- "religion": candidate.get("religion", ""),
430
- "compatibility_score": round(score * 100, 1),
431
- "photo_url": candidate.get("profile_picture_url"),
432
  }
433
  )
434
 
435
- logger.info(f"Generated {len(matches)} recommendations for {user_id}")
436
- return {
437
- "user_id": user_id,
438
- "matches": matches,
439
- "total_matches": len(matches),
440
- "query_timestamp": datetime.utcnow().isoformat(),
441
- }
442
 
443
- except HTTPException:
444
- raise
445
  except Exception as e:
446
- logger.error(f"Error in recommend: {e}")
447
- raise HTTPException(500, f"Recommendation failed: {e}")
448
 
449
 
450
  @app.post("/feedback")
451
- async def feedback(request: dict):
452
- try:
453
- user_id = request.get("user_id")
454
- target_id = request.get("target_id")
455
- action = request.get("action", "").lower()
456
-
457
- if not user_id or not target_id:
458
- raise HTTPException(400, "user_id and target_id required")
459
- if action not in ("like", "reject"):
460
- raise HTTPException(400, "action must be 'like' or 'reject'")
461
-
462
- data = {
463
- "user_id": user_id,
464
- "matched_user_id": target_id,
465
- "is_liked": action == "like",
466
- "is_skipped": action == "reject",
467
- "created_at": datetime.utcnow().isoformat(),
468
- }
469
-
470
- logger.info(f"Recording feedback: {user_id} {action} {target_id}")
471
- resp = httpx.post(
472
- f"{SUPABASE_URL}/rest/v1/matches",
473
- headers=HEADERS,
474
- json=data,
475
- timeout=10,
476
- )
477
- if resp.status_code in (200, 201, 204):
478
- logger.info("Feedback recorded successfully")
479
- return {"status": "ok", "message": "Feedback recorded"}
480
- else:
481
- return {"status": "error", "message": resp.text}
482
- except HTTPException:
483
- raise
484
- except Exception as e:
485
- logger.error(f"Error in feedback: {e}")
486
- return {"status": "error", "message": str(e)}
487
-
488
-
489
- @app.get("/")
490
- async def root():
491
- return {
492
- "name": "Matchmaking Recommendation API",
493
- "version": "2.0.0",
494
- "endpoints": {
495
- "health": "/health",
496
- "stats": "/stats",
497
- "recommend": "/recommend/{user_id}",
498
- "feedback": "/feedback",
499
- },
500
- "docs": "/docs",
501
- }
502
-
503
-
504
- if __name__ == "__main__":
505
- import uvicorn
506
-
507
- port = int(os.getenv("PORT", 7860))
508
- host = os.getenv("HOST", "0.0.0.0")
509
- logger.info(f"Starting server on {host}:{port}")
510
- uvicorn.run(app, host=host, port=port, log_level="info")
 
1
+ """
2
+ Enhanced FastAPI: Matchmaking Recommendation API
 
 
 
 
 
 
3
  """
4
 
5
  # ----------------------------------------------------------------------
6
+ # Imports
7
  # ----------------------------------------------------------------------
8
  import os
 
 
 
9
  import httpx
10
  import numpy as np
11
  from datetime import datetime
 
13
  from fastapi.middleware.cors import CORSMiddleware
14
  from sklearn.metrics.pairwise import cosine_similarity
15
  import logging
16
+ import joblib
 
 
 
 
17
 
18
  # ----------------------------------------------------------------------
19
+ # Logging
20
  # ----------------------------------------------------------------------
21
  logging.basicConfig(
22
  level=logging.INFO,
23
  format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
24
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
+ logger = logging.getLogger(__name__)
27
 
28
  # ----------------------------------------------------------------------
29
+ # Config
30
  # ----------------------------------------------------------------------
31
  SUPABASE_URL = os.getenv(
32
+ "SUPABASE_URL",
33
+ "https://nquhiryqtbrtpauuxmsc.supabase.co"
34
  )
35
+
36
  SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY", "")
37
  HEADERS = {"apikey": SERVICE_KEY, "Content-Type": "application/json"}
38
+
39
  MODEL_PATH = os.getenv("MODEL_PATH", "models/recommendation_model.pkl")
40
 
41
+ # ----------------------------------------------------------------------
42
+ # App
43
+ # ----------------------------------------------------------------------
44
  app = FastAPI(
45
  title="Matchmaking Recommendation API",
46
  version="2.0.0",
47
  description="Smart matchmaking using ML-based compatibility scoring",
48
  )
49
 
 
 
 
50
  app.add_middleware(
51
  CORSMiddleware,
52
  allow_origins=["*"],
 
56
  )
57
 
58
  # ----------------------------------------------------------------------
59
+ # Global model
60
  # ----------------------------------------------------------------------
61
  model = None
62
  model_loaded_at = None
63
 
64
 
65
+ # ----------------------------------------------------------------------
66
+ # Load model (FIXED)
67
+ # ----------------------------------------------------------------------
68
+ def load_model():
69
  global model, model_loaded_at
70
 
71
  if not os.path.exists(MODEL_PATH):
 
72
  raise FileNotFoundError(f"Model not found at {MODEL_PATH}")
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  try:
75
+ model = joblib.load(MODEL_PATH)
 
 
 
 
 
 
 
76
 
77
+ if not isinstance(model, dict):
78
+ raise ValueError("Model must be a dictionary")
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
+ required_keys = {"n_profiles", "trained_at", "feature_matrix"}
81
+ missing = required_keys - set(model.keys())
82
+
83
+ if missing:
84
+ raise ValueError(f"Missing keys in model: {missing}")
85
+
86
+ model_loaded_at = datetime.utcnow()
87
+
88
+ logger.info(
89
+ f"Model loaded: {model['n_profiles']} profiles"
90
+ )
91
+
92
+ except Exception as e:
93
+ logger.error(f"Model load failed: {e}")
94
+ raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
 
97
+ # ----------------------------------------------------------------------
98
+ # Startup
99
+ # ----------------------------------------------------------------------
100
  @app.on_event("startup")
101
  async def startup():
102
+ load_model()
 
 
 
 
103
 
104
 
105
  # ----------------------------------------------------------------------
106
+ # Helpers
107
  # ----------------------------------------------------------------------
108
  def safe_str(val):
109
  if val is None:
 
114
 
115
 
116
  def preprocess_user(user_data):
117
+ age = int(user_data.get("age") or 25)
118
+ age = max(18, min(100, age))
 
 
 
119
 
120
  cat_cols = [
121
  "religion",
 
125
  "maslak",
126
  "region_caste",
127
  ]
128
+
129
+ cat_vals = [safe_str(user_data.get(c)) or "unknown" for c in cat_cols]
 
130
 
131
  text_fields = {
132
  "hobbies": safe_str(user_data.get("hobbies")),
 
135
  user_data.get("preferred_partner_criteria")
136
  ),
137
  }
138
+
139
  return age, cat_vals, text_fields
140
 
141
 
142
  def encode_user(age, cat_vals, text_fields, le_dict, scaler, tfidf_dict):
143
  features = []
144
 
145
+ # Age
146
  try:
147
+ features.extend(scaler.transform([[float(age)]])[0])
148
+ except:
149
+ features.append(0.5)
 
150
 
151
+ # categorical
152
  cat_cols = [
153
  "religion",
154
  "marital_status",
 
157
  "maslak",
158
  "region_caste",
159
  ]
160
+
161
  for col, val in zip(cat_cols, cat_vals):
162
  le = le_dict[col]
163
+
164
  if val not in le.classes_:
165
+ val = "unknown" if "unknown" in le.classes_ else le.classes_[0]
166
+
 
 
167
  try:
168
+ features.append(float(le.transform([val])[0]))
169
+ except:
 
170
  features.append(0.0)
171
 
172
+ # text TF-IDF
173
+ text_cols = [
174
+ "hobbies",
175
+ "personality_traits",
176
+ "preferred_partner_criteria",
177
+ ]
178
+
179
  for col in text_cols:
180
  try:
181
+ vec = tfidf_dict[col].transform([text_fields[col]]).toarray()[0]
 
182
  features.extend(vec)
183
+ except:
184
  features.extend([0.0] * 20)
185
 
186
  return np.array(features).reshape(1, -1)
187
 
188
 
189
  # ----------------------------------------------------------------------
190
+ # Routes
191
  # ----------------------------------------------------------------------
192
+ @app.get("/")
193
+ async def root():
194
+ return {"status": "API running"}
195
+
196
+
197
  @app.get("/health")
198
  async def health():
199
  return {
200
+ "status": "ok",
201
  "model_loaded": model is not None,
202
+ "profiles": model["n_profiles"] if model else 0,
203
+ "loaded_at": model_loaded_at,
 
 
 
204
  }
205
 
206
 
 
208
  async def stats():
209
  if not model:
210
  raise HTTPException(503, "Model not loaded")
211
+
212
  return {
213
  "total_profiles": model["n_profiles"],
214
  "feature_dimensions": model["feature_matrix"].shape[1],
 
215
  "trained_at": model["trained_at"],
 
 
 
 
 
 
 
216
  }
217
 
218
 
219
  @app.get("/recommend/{user_id}")
220
+ async def recommend(user_id: str, top_n: int = 10):
221
  if not model:
222
  raise HTTPException(503, "Model not loaded")
223
 
 
 
 
224
  try:
 
225
  resp = httpx.get(
226
  f"{SUPABASE_URL}/rest/v1/profiles",
227
  headers=HEADERS,
228
  params={"id": f"eq.{user_id}", "select": "*"},
 
229
  )
230
+
231
  if resp.status_code != 200:
232
  raise HTTPException(404, "User not found")
 
 
 
 
233
 
234
+ user = resp.json()[0]
 
 
 
235
 
236
  age, cat_vals, text_fields = preprocess_user(user)
237
+
238
  user_vec = encode_user(
239
  age,
240
  cat_vals,
 
244
  model["tfidf_vectorizers"],
245
  )
246
 
247
+ candidates = [
248
+ (i, p)
249
+ for i, p in enumerate(model["profile_data"])
250
+ if p.get("gender") != user.get("gender")
251
+ and p.get("id") != user_id
252
+ ]
 
 
253
 
254
  if not candidates:
255
  return {"matches": []}
256
 
257
+ indices = [c[0] for c in candidates]
258
+ matrix = model["feature_matrix"][indices]
259
+
260
+ sims = cosine_similarity(user_vec, matrix)[0]
261
 
262
+ top = np.argsort(sims)[::-1][:top_n]
263
 
264
+ results = []
265
+ for i in top:
266
+ p = candidates[i][1]
267
+ results.append(
 
 
 
 
268
  {
269
+ "user_id": p["id"],
270
+ "name": p.get("full_name"),
271
+ "score": float(sims[i]),
 
 
 
 
 
272
  }
273
  )
274
 
275
+ return {"matches": results}
 
 
 
 
 
 
276
 
 
 
277
  except Exception as e:
278
+ raise HTTPException(500, str(e))
 
279
 
280
 
281
  @app.post("/feedback")
282
+ async def feedback(data: dict):
283
+ return {"status": "received"}