subhan971 commited on
Commit
3faf474
·
verified ·
1 Parent(s): 129dc46

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +333 -207
app.py CHANGED
@@ -1,22 +1,16 @@
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
  import os
12
- import pickle
13
  import httpx
14
  import numpy as np
15
- from datetime import datetime
 
16
  from fastapi import FastAPI, HTTPException
17
  from fastapi.middleware.cors import CORSMiddleware
18
  from sklearn.metrics.pairwise import cosine_similarity
 
 
19
  import logging
 
 
20
 
21
  # Setup logging
22
  logging.basicConfig(
@@ -29,15 +23,17 @@ logger = logging.getLogger(__name__)
29
  SUPABASE_URL = os.getenv('SUPABASE_URL', 'https://nquhiryqtbrtpauuxmsc.supabase.co')
30
  SERVICE_KEY = os.getenv('SUPABASE_SERVICE_KEY', '')
31
  HEADERS = {'apikey': SERVICE_KEY, 'Content-Type': 'application/json'}
32
- MODEL_PATH = os.getenv('MODEL_PATH', 'models/recommendation_model.pkl')
 
 
 
33
 
34
  app = FastAPI(
35
- title="Matchmaking Recommendation API",
36
- version="2.0.0",
37
- description="Smart matchmaking using ML-based compatibility scoring"
38
  )
39
 
40
- # CORS middleware
41
  app.add_middleware(
42
  CORSMiddleware,
43
  allow_origins=["*"],
@@ -46,41 +42,82 @@ app.add_middleware(
46
  allow_headers=["*"],
47
  )
48
 
49
- # Global model
50
- model = None
51
- model_loaded_at = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
 
54
- def load_model():
55
- """Load trained model from disk"""
56
- global model, model_loaded_at
57
-
58
- if not os.path.exists(MODEL_PATH):
59
- logger.error(f"Model file not found at {MODEL_PATH}")
60
- raise Exception(f"Model not found at {MODEL_PATH}")
61
-
62
- try:
63
- with open(MODEL_PATH, 'rb') as f:
64
- model = pickle.load(f)
65
- model_loaded_at = datetime.now()
66
- logger.info(f"✓ Model loaded: {model['n_profiles']} profiles, trained at {model['trained_at']}")
67
- return True
68
- except Exception as e:
69
- logger.error(f"✗ Error loading model: {e}")
70
- raise
71
 
 
 
 
72
 
73
- @app.on_event("startup")
74
- async def startup():
75
- """Load model on startup"""
 
 
 
76
  try:
77
- load_model()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  except Exception as e:
79
- logger.error(f"[STARTUP ERROR] {e}")
 
80
 
81
 
82
  def safe_str(val):
83
- """Safe string conversion with None and list handling"""
84
  if val is None:
85
  return ''
86
  if isinstance(val, list):
@@ -88,184 +125,271 @@ def safe_str(val):
88
  return str(val).strip().lower()
89
 
90
 
91
- def preprocess_user(user_data):
92
- """Extract and clean features from Supabase user dict"""
93
- # Age
94
  try:
95
- age = int(user_data.get('age') or 25)
96
- age = max(18, min(100, age)) # Bounds check
97
  except:
98
  age = 25
99
 
100
- # Categoricals in order: religion, marital_status, qualification, country, maslak, region_caste
101
- cat_cols = ['religion', 'marital_status', 'qualification', 'country', 'maslak', 'region_caste']
102
- cat_vals = [safe_str(user_data.get(col)) or 'unknown' for col in cat_cols]
103
 
104
- # Text fields
105
- text_fields = {
106
- 'hobbies': safe_str(user_data.get('hobbies')),
107
- 'personality_traits': safe_str(user_data.get('personality_traits')),
108
- 'preferred_partner_criteria': safe_str(user_data.get('preferred_partner_criteria'))
 
 
 
 
 
 
 
 
 
 
 
 
109
  }
110
 
111
- return age, cat_vals, text_fields
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
 
114
- def encode_user(age, cat_vals, text_fields, le_dict, scaler, tfidf_dict):
115
- """
116
- Build feature vector for user using fitted transformers.
117
- Order: age (1) + categoricals (6) + tfidf_hobbies (20) + tfidf_traits (20) + tfidf_criteria (20)
118
- Total: 1 + 6 + 60 = 67 dimensions
119
- """
 
120
  features = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
- # 1. Scale age
 
 
 
 
123
  try:
124
- age_scaled = scaler.transform([[float(age)]])[0]
125
- features.extend(age_scaled)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  except Exception as e:
127
- logger.warning(f"Error scaling age: {e}")
128
- features.extend([0.5]) # Default: middle of range
129
-
130
- # 2. Encode categoricals (in same order as training)
131
- cat_cols = ['religion', 'marital_status', 'qualification', 'country', 'maslak', 'region_caste']
132
- for col, val in zip(cat_cols, cat_vals):
133
- le = le_dict[col]
134
-
135
- # Handle unknown categories
136
- if val not in le.classes_:
137
- if 'unknown' in le.classes_:
138
- val = 'unknown'
139
- else:
140
- val = le.classes_[0]
141
-
142
- try:
143
- encoded = le.transform([val])[0]
144
- features.append(float(encoded))
145
- except Exception as e:
146
- logger.warning(f"Error encoding {col}={val}: {e}")
147
- features.append(0.0)
148
-
149
- # 3. TF-IDF for text fields
150
- text_cols = ['hobbies', 'personality_traits', 'preferred_partner_criteria']
151
- for col in text_cols:
152
- try:
153
- tfidf = tfidf_dict[col]
154
- vec = tfidf.transform([text_fields[col]]).toarray()[0]
155
- features.extend(vec)
156
- except Exception as e:
157
- logger.warning(f"Error TF-IDF encoding {col}: {e}")
158
- features.extend([0.0] * 20) # 20 default features
159
-
160
- return np.array(features).reshape(1, -1)
161
 
162
 
163
  @app.get("/health")
164
  async def health():
165
- """Health check endpoint"""
166
  return {
167
  "status": "running",
168
- "model_loaded": model is not None,
169
- "model_profiles": model['n_profiles'] if model else 0,
170
- "model_trained_at": model['trained_at'] if model else None,
171
- "api_loaded_at": model_loaded_at.isoformat() if model_loaded_at else None
 
 
 
172
  }
173
 
174
 
175
  @app.get("/stats")
176
  async def stats():
177
- """Get model statistics"""
178
- if not model:
179
- raise HTTPException(503, "Model not loaded")
 
 
180
 
181
  return {
182
- "total_profiles": model['n_profiles'],
183
- "feature_dimensions": model['feature_matrix'].shape[1],
184
- "model_version": model['version'],
185
- "trained_at": model['trained_at'],
186
- "categorical_features": len(['religion', 'marital_status', 'qualification', 'country', 'maslak', 'region_caste']),
187
- "text_features": len(['hobbies', 'personality_traits', 'preferred_partner_criteria']),
188
- "numeric_features": 1 # age
189
  }
190
 
191
 
192
  @app.get("/recommend/{user_id}")
193
  async def recommend(user_id: str, top_n: int = 10, min_score: float = 0.3):
194
- """
195
- Get top N compatible matches for a user
196
-
197
- Args:
198
- user_id: UUID of the user
199
- top_n: Number of recommendations (default: 10, max: 50)
200
- min_score: Minimum compatibility score (0.0-1.0, default: 0.3)
201
-
202
- Returns:
203
- list of matches with compatibility scores
204
- """
205
-
206
- if not model:
207
- raise HTTPException(503, "Model not loaded")
208
 
209
  # Validate params
210
  top_n = min(max(1, top_n), 50)
211
  min_score = max(0.0, min(1.0, min_score))
212
-
213
  try:
214
- # 1. Fetch query user from Supabase
215
- logger.info(f"Fetching user {user_id}")
216
- resp = httpx.get(
217
- f"{SUPABASE_URL}/rest/v1/profiles",
218
- headers=HEADERS,
219
- params={'id': f'eq.{user_id}', 'select': '*'},
220
- timeout=10
221
- )
222
-
223
- if resp.status_code != 200:
224
- logger.warning(f"User fetch failed: {resp.status_code}")
225
- raise HTTPException(404, "User not found")
226
 
227
- users = resp.json()
228
- if not users:
229
- raise HTTPException(404, "User not found")
230
 
231
- user = users[0]
 
 
 
 
 
 
 
232
 
233
- # 2. Validate gender
234
- user_gender = safe_str(user.get('gender'))
235
- if user_gender not in ('male', 'female'):
236
- raise HTTPException(400, "User gender must be 'male' or 'female'")
237
 
238
- opposite_gender = 'female' if user_gender == 'male' else 'male'
 
 
239
 
240
- # 3. Encode query user
241
- age, cat_vals, text_fields = preprocess_user(user)
242
- user_vec = encode_user(
243
- age, cat_vals, text_fields,
244
- model['label_encoders'],
245
- model['scaler'],
246
- model['tfidf_vectorizers']
247
- )
248
 
249
- # 4. Get candidates (opposite gender, active)
250
- candidates = []
251
- for i, p in enumerate(model['profile_data']):
252
- if (p.get('gender') == opposite_gender and
253
- p.get('is_active') and
254
- p.get('id') != user_id): # Exclude self
255
- candidates.append((i, p))
256
 
257
- if len(candidates) < 1:
258
- logger.info(f"No candidates found for {user_id}")
259
- return {"matches": []}
 
 
 
 
260
 
261
- # 5. Get candidate feature vectors
262
- cand_indices = [c[0] for c in candidates]
263
- cand_matrix = model['feature_matrix'][cand_indices]
264
 
265
- # 6. Compute cosine similarity
266
  sims = cosine_similarity(user_vec, cand_matrix)[0]
267
 
268
- # 7. Get top N matches above threshold
269
  top_positions = np.argsort(sims)[::-1][:top_n]
270
 
271
  matches = []
@@ -275,16 +399,17 @@ async def recommend(user_id: str, top_n: int = 10, min_score: float = 0.3):
275
  if score < min_score:
276
  continue
277
 
278
- candidate = candidates[pos][1]
 
 
279
  matches.append({
280
- "user_id": candidate['id'],
281
- "name": candidate.get('full_name', 'Unknown'),
282
- "age": candidate.get('age', 0),
283
- "city": candidate.get('city', ''),
284
- "gender": candidate.get('gender', ''),
285
- "religion": candidate.get('religion', ''),
286
  "compatibility_score": round(score * 100, 1),
287
- "photo_url": candidate.get('profile_picture_url')
288
  })
289
 
290
  logger.info(f"Generated {len(matches)} recommendations for {user_id}")
@@ -305,30 +430,18 @@ async def recommend(user_id: str, top_n: int = 10, min_score: float = 0.3):
305
 
306
  @app.post("/feedback")
307
  async def feedback(request: dict):
308
- """
309
- Record user feedback (like/reject)
310
-
311
- Args:
312
- request: {user_id, target_id, action}
313
- action: "like" or "reject"
314
-
315
- Returns:
316
- status confirmation
317
- """
318
-
319
  try:
320
  user_id = request.get('user_id')
321
  target_id = request.get('target_id')
322
  action = request.get('action', '').lower()
323
 
324
- # Validate input
325
  if not user_id or not target_id:
326
  raise HTTPException(400, "user_id and target_id required")
327
 
328
  if action not in ('like', 'reject'):
329
  raise HTTPException(400, "action must be 'like' or 'reject'")
330
 
331
- # Prepare data for Supabase
332
  data = {
333
  'user_id': user_id,
334
  'matched_user_id': target_id,
@@ -337,9 +450,6 @@ async def feedback(request: dict):
337
  'created_at': datetime.utcnow().isoformat()
338
  }
339
 
340
- # Upsert to Supabase matches table
341
- logger.info(f"Recording feedback: {user_id} {action} {target_id}")
342
-
343
  resp = httpx.post(
344
  f"{SUPABASE_URL}/rest/v1/matches",
345
  headers=HEADERS,
@@ -348,10 +458,10 @@ async def feedback(request: dict):
348
  )
349
 
350
  if resp.status_code in (200, 201, 204):
351
- logger.info(f"Feedback recorded successfully")
352
  return {"status": "ok", "message": "Feedback recorded"}
353
  else:
354
- logger.warning(f"Supabase response: {resp.status_code} - {resp.text}")
355
  return {"status": "error", "message": resp.text}
356
 
357
  except HTTPException:
@@ -361,19 +471,35 @@ async def feedback(request: dict):
361
  return {"status": "error", "message": str(e)}
362
 
363
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  @app.get("/")
365
  async def root():
366
  """Root endpoint"""
367
  return {
368
- "name": "Matchmaking Recommendation API",
369
- "version": "2.0.0",
 
370
  "endpoints": {
371
  "health": "/health",
372
  "stats": "/stats",
373
- "recommend": "/recommend/{user_id}",
374
- "feedback": "/feedback"
375
- },
376
- "docs": "/docs"
 
377
  }
378
 
379
 
@@ -382,5 +508,5 @@ if __name__ == '__main__':
382
  port = int(os.getenv('PORT', 7860))
383
  host = os.getenv('HOST', '0.0.0.0')
384
 
385
- logger.info(f"Starting server on {host}:{port}")
386
  uvicorn.run(app, host=host, port=port, log_level="info")
 
 
 
 
 
 
 
 
 
 
 
1
  import os
 
2
  import httpx
3
  import numpy as np
4
+ import asyncio
5
+ from datetime import datetime, timedelta
6
  from fastapi import FastAPI, HTTPException
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from sklearn.metrics.pairwise import cosine_similarity
9
+ from sklearn.feature_extraction.text import TfidfVectorizer
10
+ from sklearn.preprocessing import StandardScaler
11
  import logging
12
+ from functools import lru_cache
13
+ import hashlib
14
 
15
  # Setup logging
16
  logging.basicConfig(
 
23
  SUPABASE_URL = os.getenv('SUPABASE_URL', 'https://nquhiryqtbrtpauuxmsc.supabase.co')
24
  SERVICE_KEY = os.getenv('SUPABASE_SERVICE_KEY', '')
25
  HEADERS = {'apikey': SERVICE_KEY, 'Content-Type': 'application/json'}
26
+
27
+ # Cache settings
28
+ CACHE_TTL = 300 # 5 minutes
29
+ PROFILE_BATCH_SIZE = 100 # Fetch profiles in batches
30
 
31
  app = FastAPI(
32
+ title="Real-time Matchmaking API",
33
+ version="3.0.0",
34
+ description="Live ML-based matching without pickle files - instant updates"
35
  )
36
 
 
37
  app.add_middleware(
38
  CORSMiddleware,
39
  allow_origins=["*"],
 
42
  allow_headers=["*"],
43
  )
44
 
45
+ # Global cache
46
+ class MatchmakingCache:
47
+ def __init__(self):
48
+ self.profiles = []
49
+ self.feature_matrix = None
50
+ self.profile_ids = []
51
+ self.last_updated = None
52
+ self.cache_valid = False
53
+ self.tfidf_vectorizers = {}
54
+ self.scaler = StandardScaler()
55
+
56
+ def is_expired(self):
57
+ """Check if cache is expired"""
58
+ if not self.last_updated:
59
+ return True
60
+ return datetime.now() - self.last_updated > timedelta(seconds=CACHE_TTL)
61
+
62
+ def invalidate(self):
63
+ """Invalidate cache"""
64
+ self.cache_valid = False
65
+ logger.info("Cache invalidated")
66
 
67
 
68
+ cache = MatchmakingCache()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
+ # Feature config
71
+ CATEGORICAL_COLS = ['religion', 'marital_status', 'qualification', 'country', 'maslak', 'region_caste']
72
+ TEXT_COLS = ['hobbies', 'personality_traits', 'preferred_partner_criteria']
73
 
74
+
75
+ async def fetch_profiles_paginated(limit=1000):
76
+ """Fetch profiles with pagination"""
77
+ all_profiles = []
78
+ offset = 0
79
+
80
  try:
81
+ while offset < limit:
82
+ try:
83
+ resp = httpx.get(
84
+ f"{SUPABASE_URL}/rest/v1/profiles",
85
+ headers=HEADERS,
86
+ params={
87
+ 'select': '*',
88
+ 'is_active': 'eq.true',
89
+ 'limit': PROFILE_BATCH_SIZE,
90
+ 'offset': offset
91
+ },
92
+ timeout=15
93
+ )
94
+
95
+ if resp.status_code != 200:
96
+ logger.warning(f"Fetch failed at offset {offset}: {resp.status_code}")
97
+ break
98
+
99
+ batch = resp.json()
100
+ if not batch:
101
+ break
102
+
103
+ all_profiles.extend(batch)
104
+ offset += PROFILE_BATCH_SIZE
105
+ logger.debug(f"Fetched {len(batch)} profiles, total: {len(all_profiles)}")
106
+
107
+ except Exception as e:
108
+ logger.warning(f"Error fetching batch: {e}")
109
+ break
110
+
111
+ logger.info(f"✓ Fetched {len(all_profiles)} active profiles")
112
+ return all_profiles
113
+
114
  except Exception as e:
115
+ logger.error(f"✗ Error in pagination: {e}")
116
+ return all_profiles if all_profiles else []
117
 
118
 
119
  def safe_str(val):
120
+ """Safe string conversion"""
121
  if val is None:
122
  return ''
123
  if isinstance(val, list):
 
125
  return str(val).strip().lower()
126
 
127
 
128
+ def preprocess_profile(profile):
129
+ """Convert Supabase profile to feature-ready dict"""
 
130
  try:
131
+ age = int(profile.get('age') or 25)
132
+ age = max(18, min(100, age)) # Bounds
133
  except:
134
  age = 25
135
 
136
+ cat = {col: safe_str(profile.get(col)) or 'unknown' for col in CATEGORICAL_COLS}
137
+ text = {col: safe_str(profile.get(col)) for col in TEXT_COLS}
 
138
 
139
+ return {
140
+ 'id': profile['id'],
141
+ 'full_name': profile.get('full_name', 'Unknown'),
142
+ 'age': age,
143
+ 'gender': safe_str(profile.get('gender')),
144
+ 'city': profile.get('city', ''),
145
+ 'religion': cat['religion'],
146
+ 'marital_status': cat['marital_status'],
147
+ 'qualification': cat['qualification'],
148
+ 'country': cat['country'],
149
+ 'maslak': cat['maslak'],
150
+ 'region_caste': cat['region_caste'],
151
+ 'hobbies': text['hobbies'],
152
+ 'personality_traits': text['personality_traits'],
153
+ 'preferred_partner_criteria': text['preferred_partner_criteria'],
154
+ 'profile_picture_url': profile.get('profile_picture_url'),
155
+ 'is_active': profile.get('is_active', True)
156
  }
157
 
158
+
159
+ def build_categorical_features(profiles):
160
+ """Build one-hot encoded categorical features"""
161
+ cat_features = []
162
+
163
+ for col in CATEGORICAL_COLS:
164
+ # Get unique values from profiles
165
+ unique_vals = sorted(set(p[col] for p in profiles if p[col]))
166
+
167
+ # Create mapping
168
+ val_to_idx = {val: idx for idx, val in enumerate(unique_vals)}
169
+
170
+ # Encode
171
+ encoded = []
172
+ for p in profiles:
173
+ val = p[col]
174
+ idx = val_to_idx.get(val, 0)
175
+ # Create one-hot vector
176
+ one_hot = np.zeros(len(unique_vals))
177
+ one_hot[idx] = 1
178
+ encoded.append(one_hot)
179
+
180
+ cat_features.append(np.array(encoded))
181
+
182
+ return np.hstack(cat_features) if cat_features else np.array([])
183
+
184
+
185
+ def build_text_features(profiles, vectorizers=None):
186
+ """Build TF-IDF features for text columns"""
187
+ text_features = []
188
+ new_vectorizers = {}
189
+
190
+ for col in TEXT_COLS:
191
+ corpus = [p[col] for p in profiles]
192
+
193
+ if vectorizers and col in vectorizers:
194
+ # Use existing vectorizer
195
+ vec = vectorizers[col].transform(corpus).toarray()
196
+ new_vectorizers[col] = vectorizers[col]
197
+ else:
198
+ # Create new vectorizer
199
+ tfidf = TfidfVectorizer(
200
+ max_features=15,
201
+ stop_words='english',
202
+ token_pattern=r'(?u)\b\w+\b',
203
+ min_df=1
204
+ )
205
+ vec = tfidf.fit_transform(corpus).toarray()
206
+ new_vectorizers[col] = tfidf
207
+
208
+ text_features.append(vec)
209
+
210
+ return np.hstack(text_features) if text_features else np.array([]), new_vectorizers
211
 
212
 
213
+ def build_feature_matrix(profiles, vectorizers=None):
214
+ """Build complete feature matrix: age + categoricals + text"""
215
+ n = len(profiles)
216
+
217
+ if n == 0:
218
+ return np.array([]), {}, None
219
+
220
  features = []
221
+
222
+ # 1. Age (normalized)
223
+ ages = np.array([[float(p['age'])] for p in profiles])
224
+ scaler = StandardScaler()
225
+ ages_norm = scaler.fit_transform(ages)
226
+ features.append(ages_norm)
227
+
228
+ # 2. Categorical features (one-hot encoded)
229
+ cat_feat = build_categorical_features(profiles)
230
+ if cat_feat.size > 0:
231
+ features.append(cat_feat)
232
+
233
+ # 3. Text features (TF-IDF)
234
+ text_feat, new_vectorizers = build_text_features(profiles, vectorizers)
235
+ if text_feat.size > 0:
236
+ features.append(text_feat)
237
+
238
+ X = np.hstack(features)
239
+
240
+ logger.info(f"Feature matrix built: {X.shape}")
241
+ return X, new_vectorizers, scaler
242
 
243
+
244
+ async def refresh_cache():
245
+ """Refresh profile cache from Supabase"""
246
+ global cache
247
+
248
  try:
249
+ logger.info("🔄 Refreshing profile cache...")
250
+
251
+ # Fetch profiles
252
+ raw_profiles = await fetch_profiles_paginated()
253
+
254
+ if not raw_profiles or len(raw_profiles) < 5:
255
+ logger.warning(f"⚠️ Only {len(raw_profiles)} profiles found")
256
+ return False
257
+
258
+ # Preprocess
259
+ profiles = [preprocess_profile(p) for p in raw_profiles]
260
+
261
+ # Build features
262
+ X, vectorizers, scaler = build_feature_matrix(profiles, cache.tfidf_vectorizers)
263
+
264
+ if X.size == 0:
265
+ logger.warning("Feature matrix is empty")
266
+ return False
267
+
268
+ # Update cache
269
+ cache.profiles = profiles
270
+ cache.feature_matrix = X
271
+ cache.profile_ids = [p['id'] for p in profiles]
272
+ cache.tfidf_vectorizers = vectorizers
273
+ cache.scaler = scaler
274
+ cache.last_updated = datetime.now()
275
+ cache.cache_valid = True
276
+
277
+ logger.info(f"✓ Cache refreshed: {len(profiles)} profiles, {X.shape[1]} features")
278
+ return True
279
+
280
  except Exception as e:
281
+ logger.error(f"✗ Cache refresh failed: {e}")
282
+ return False
283
+
284
+
285
+ async def get_cached_profiles():
286
+ """Get profiles, refresh if needed"""
287
+ if not cache.cache_valid or cache.is_expired():
288
+ success = await refresh_cache()
289
+ if not success:
290
+ if not cache.profiles:
291
+ raise Exception("No profiles available")
292
+ return cache.profiles, cache.feature_matrix
293
+
294
+
295
+ @app.on_event("startup")
296
+ async def startup():
297
+ """Initial cache load"""
298
+ try:
299
+ await refresh_cache()
300
+ logger.info("✓ Startup cache loaded")
301
+ except Exception as e:
302
+ logger.warning(f"⚠️ Startup cache load failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
303
 
304
 
305
  @app.get("/health")
306
  async def health():
307
+ """Health check"""
308
  return {
309
  "status": "running",
310
+ "cache_valid": cache.cache_valid,
311
+ "profiles_cached": len(cache.profiles),
312
+ "last_updated": cache.last_updated.isoformat() if cache.last_updated else None,
313
+ "cache_expires_in": (
314
+ max(0, int(CACHE_TTL - (datetime.now() - cache.last_updated).total_seconds()))
315
+ if cache.last_updated else 0
316
+ )
317
  }
318
 
319
 
320
  @app.get("/stats")
321
  async def stats():
322
+ """Model statistics"""
323
+ profiles, X = await get_cached_profiles()
324
+
325
+ if X is None:
326
+ return {"error": "Model not ready"}
327
 
328
  return {
329
+ "total_profiles": len(profiles),
330
+ "feature_dimensions": X.shape[1],
331
+ "categorical_features": len(CATEGORICAL_COLS),
332
+ "text_features": len(TEXT_COLS),
333
+ "numeric_features": 1,
334
+ "cache_age_seconds": (datetime.now() - cache.last_updated).total_seconds() if cache.last_updated else None,
335
+ "active_profiles": sum(1 for p in profiles if p['is_active'])
336
  }
337
 
338
 
339
  @app.get("/recommend/{user_id}")
340
  async def recommend(user_id: str, top_n: int = 10, min_score: float = 0.3):
341
+ """Real-time recommendations"""
 
 
 
 
 
 
 
 
 
 
 
 
 
342
 
343
  # Validate params
344
  top_n = min(max(1, top_n), 50)
345
  min_score = max(0.0, min(1.0, min_score))
346
+
347
  try:
348
+ # Get fresh profiles
349
+ profiles, X = await get_cached_profiles()
 
 
 
 
 
 
 
 
 
 
350
 
351
+ if X is None or len(profiles) < 2:
352
+ raise HTTPException(503, "Insufficient profiles for matching")
 
353
 
354
+ # Find query user
355
+ user = None
356
+ user_idx = None
357
+ for i, p in enumerate(profiles):
358
+ if p['id'] == user_id:
359
+ user = p
360
+ user_idx = i
361
+ break
362
 
363
+ if user is None:
364
+ raise HTTPException(404, "User not found")
 
 
365
 
366
+ # Validate gender
367
+ if user['gender'] not in ('male', 'female'):
368
+ raise HTTPException(400, "Invalid gender")
369
 
370
+ opposite_gender = 'female' if user['gender'] == 'male' else 'male'
 
 
 
 
 
 
 
371
 
372
+ # Find candidate indices (opposite gender, active, not self)
373
+ cand_indices = [
374
+ i for i, p in enumerate(profiles)
375
+ if p['gender'] == opposite_gender and p['is_active'] and p['id'] != user_id
376
+ ]
 
 
377
 
378
+ if not cand_indices:
379
+ return {
380
+ "user_id": user_id,
381
+ "matches": [],
382
+ "total_matches": 0,
383
+ "query_timestamp": datetime.utcnow().isoformat()
384
+ }
385
 
386
+ # Compute similarity
387
+ user_vec = X[user_idx].reshape(1, -1)
388
+ cand_matrix = X[cand_indices]
389
 
 
390
  sims = cosine_similarity(user_vec, cand_matrix)[0]
391
 
392
+ # Get top N
393
  top_positions = np.argsort(sims)[::-1][:top_n]
394
 
395
  matches = []
 
399
  if score < min_score:
400
  continue
401
 
402
+ cand_idx = cand_indices[pos]
403
+ cand = profiles[cand_idx]
404
+
405
  matches.append({
406
+ "user_id": cand['id'],
407
+ "name": cand['full_name'],
408
+ "age": cand['age'],
409
+ "city": cand['city'],
410
+ "religion": cand['religion'],
 
411
  "compatibility_score": round(score * 100, 1),
412
+ "photo_url": cand['profile_picture_url']
413
  })
414
 
415
  logger.info(f"Generated {len(matches)} recommendations for {user_id}")
 
430
 
431
  @app.post("/feedback")
432
  async def feedback(request: dict):
433
+ """Record user feedback (like/reject)"""
 
 
 
 
 
 
 
 
 
 
434
  try:
435
  user_id = request.get('user_id')
436
  target_id = request.get('target_id')
437
  action = request.get('action', '').lower()
438
 
 
439
  if not user_id or not target_id:
440
  raise HTTPException(400, "user_id and target_id required")
441
 
442
  if action not in ('like', 'reject'):
443
  raise HTTPException(400, "action must be 'like' or 'reject'")
444
 
 
445
  data = {
446
  'user_id': user_id,
447
  'matched_user_id': target_id,
 
450
  'created_at': datetime.utcnow().isoformat()
451
  }
452
 
 
 
 
453
  resp = httpx.post(
454
  f"{SUPABASE_URL}/rest/v1/matches",
455
  headers=HEADERS,
 
458
  )
459
 
460
  if resp.status_code in (200, 201, 204):
461
+ logger.info(f"Feedback recorded: {user_id} {action} {target_id}")
462
  return {"status": "ok", "message": "Feedback recorded"}
463
  else:
464
+ logger.warning(f"Supabase error: {resp.status_code}")
465
  return {"status": "error", "message": resp.text}
466
 
467
  except HTTPException:
 
471
  return {"status": "error", "message": str(e)}
472
 
473
 
474
+ @app.post("/refresh-cache")
475
+ async def manual_refresh():
476
+ """Manually trigger cache refresh"""
477
+ try:
478
+ success = await refresh_cache()
479
+ if success:
480
+ return {"status": "ok", "message": "Cache refreshed"}
481
+ else:
482
+ return {"status": "error", "message": "Refresh failed"}
483
+ except Exception as e:
484
+ logger.error(f"Manual refresh error: {e}")
485
+ return {"status": "error", "message": str(e)}
486
+
487
+
488
  @app.get("/")
489
  async def root():
490
  """Root endpoint"""
491
  return {
492
+ "name": "Real-time Matchmaking API",
493
+ "version": "3.0.0",
494
+ "architecture": "No pickle files - Live from Supabase",
495
  "endpoints": {
496
  "health": "/health",
497
  "stats": "/stats",
498
+ "recommend": "/recommend/{user_id}?top_n=10&min_score=0.3",
499
+ "feedback": "/feedback",
500
+ "refresh_cache": "/refresh-cache",
501
+ "docs": "/docs"
502
+ }
503
  }
504
 
505
 
 
508
  port = int(os.getenv('PORT', 7860))
509
  host = os.getenv('HOST', '0.0.0.0')
510
 
511
+ logger.info(f"Starting Real-time Matchmaking API on {host}:{port}")
512
  uvicorn.run(app, host=host, port=port, log_level="info")