AlejandroCalizaya commited on
Commit
d9826a8
·
1 Parent(s): 589d762

feat: add matching api functions

Browse files
Files changed (4) hide show
  1. app/main.py +212 -19
  2. app/models.py +18 -1
  3. app/services.py +263 -0
  4. requirements.txt +6 -0
app/main.py CHANGED
@@ -1,13 +1,14 @@
1
  import os
2
  import sendgrid
3
  from sendgrid.helpers.mail import Mail
4
- from fastapi import FastAPI, HTTPException
5
  from supabase import create_client
6
  from dotenv import load_dotenv
7
  from datetime import datetime, timedelta
8
 
9
  from app.utils import *
10
  from app.models import *
 
11
 
12
 
13
  load_dotenv()
@@ -33,24 +34,6 @@ JWT_EXPIRES_IN = os.getenv("JWT_EXPIRES_IN")
33
  app = FastAPI()
34
 
35
 
36
- @app.get("/")
37
- def root():
38
- return {"success": True, "message": "API is running"}
39
-
40
-
41
- @app.get("/users")
42
- def get_users():
43
- response = supabase.table("users").select("*").execute()
44
- return response.data
45
-
46
-
47
- @app.post("/add_user")
48
- def add_user(email: str):
49
- response = supabase.table("users").insert({
50
- "email": email
51
- }).execute()
52
- return {"status": "ok", "inserted": response.data}
53
-
54
  @app.post("/auth/send-verification")
55
  def send_verification(data: EmailRequest):
56
  email = data.email
@@ -173,3 +156,213 @@ def verify(data: VerifyRequest):
173
  "onboardingCompleted": user['onboardingCompleted']
174
  }
175
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import sendgrid
3
  from sendgrid.helpers.mail import Mail
4
+ from fastapi import FastAPI, HTTPException, Query
5
  from supabase import create_client
6
  from dotenv import load_dotenv
7
  from datetime import datetime, timedelta
8
 
9
  from app.utils import *
10
  from app.models import *
11
+ from app.services import get_candidates, match_user_to_mentors
12
 
13
 
14
  load_dotenv()
 
34
  app = FastAPI()
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  @app.post("/auth/send-verification")
38
  def send_verification(data: EmailRequest):
39
  email = data.email
 
156
  "onboardingCompleted": user['onboardingCompleted']
157
  }
158
  }
159
+
160
+
161
+ @app.post("/matches/candidates")
162
+ def match_candidate(
163
+ data: UserRequest,
164
+ limit: int = Query(20),
165
+ offset: int = Query(0)
166
+ ):
167
+ user_id = data.user_id
168
+ return get_candidates(user_id, limit=limit, offset=offset, supabase=supabase)
169
+
170
+
171
+ @app.post("/matches/request")
172
+ def match_request(data: MatchRequest):
173
+ user_id = data.user_id
174
+ candidate_id = data.candidate_id
175
+ candidate_name = data.candidate_name
176
+ message = data.message
177
+ compatibility_score = data.compatibility_score
178
+
179
+ # Insert match request into the database
180
+ response = supabase.table("matches").insert({
181
+ "mentee_id": user_id,
182
+ "mentor_id": candidate_id,
183
+ "message": message,
184
+ "compatibility_score": compatibility_score,
185
+ })
186
+
187
+ if response.error:
188
+ raise HTTPException(status_code=500, detail="Error creating match request")
189
+
190
+ match_data = response.execute().data[0]
191
+
192
+ return {
193
+ "success": True,
194
+ "match": {
195
+ "id": match_data["id"],
196
+ "mentee_id": match_data["mentee_id"],
197
+ "mentor_id": match_data["mentor_id"],
198
+ "message": match_data["message"],
199
+ "compatibility_score": match_data["compatibility_score"],
200
+ "created_at": match_data["created_at"]
201
+ },
202
+ "message": f"Solicitud enviada a {candidate_name}. Te notificaremos cuando responda."
203
+ }
204
+
205
+
206
+ @app.get("/matches/skip")
207
+ def match_skip():
208
+ return {
209
+ "success": True,
210
+ "message": "Candidato omitido. Mostrando siguiente."
211
+ }
212
+
213
+
214
+ @app.post("/matches/my-matches")
215
+ def get_matches(
216
+ data: UserRequest,
217
+ status: str = Query("all"),
218
+ role: str = Query("all")
219
+ ):
220
+ user_id = data.user_id
221
+
222
+ # Build filters
223
+ query = supabase.table("matches").select("*")
224
+
225
+ if status != "all":
226
+ query = query.eq("status", status)
227
+
228
+ if role == "mentor":
229
+ query = query.eq("mentor_id", user_id)
230
+ elif role == "mentee":
231
+ query = query.eq("mentee_id", user_id)
232
+ else:
233
+ query = query.or_(f"mentor_id.eq.{user_id},mentee_id.eq.{user_id}")
234
+
235
+ # Execute
236
+ response = query.execute()
237
+ matches = response.data
238
+
239
+ full_matches = []
240
+ status_count = {
241
+ "pending": 0,
242
+ "active": 0,
243
+ "completed": 0
244
+ }
245
+
246
+ for m in matches:
247
+ if m["status"] in status_count:
248
+ status_count[m["status"]] += 1
249
+
250
+ if m["mentee_id"] == user_id:
251
+ other_user_id = m["mentor_id"]
252
+ else:
253
+ other_user_id = m["mentee_id"]
254
+
255
+ user_response = (
256
+ supabase.table("users")
257
+ .select("*")
258
+ .eq("id", other_user_id)
259
+ .execute()
260
+ )
261
+
262
+ if not user_response.data:
263
+ continue
264
+
265
+ other_user = user_response.data[0]
266
+
267
+ # Mock
268
+ last_message = {
269
+ "content": "Último mensaje de prueba",
270
+ "sentAt": "2024-11-02T10:20:00Z",
271
+ "isRead": True
272
+ }
273
+
274
+ upcoming_session = {
275
+ "id": "session-mock-1",
276
+ "scheduledAt": "2024-11-06T16:00:00Z",
277
+ "duration": 60
278
+ }
279
+
280
+ stats = {
281
+ "totalSessions": 2,
282
+ "totalMessages": 15
283
+ }
284
+
285
+ full_matches.append({
286
+ "id": m["id"],
287
+ "status": m["status"],
288
+ "matchType": m["match_type"],
289
+ "compatibilityScore": m["compatibility_score"],
290
+ "createdAt": m["created_at"],
291
+ "acceptedAt": m["accepted_at"],
292
+ "otherUser": {
293
+ "id": other_user["id"],
294
+ "firstName": other_user["firstname"],
295
+ "lastName": other_user["lastname"][0] + '.',
296
+ "profileImage": other_user["profileimage"],
297
+ "career": other_user["career"],
298
+ "semester": other_user["semester"],
299
+ },
300
+ "lastMessage": last_message,
301
+ "upcomingSession": upcoming_session,
302
+ "stats": stats
303
+ })
304
+
305
+ return {
306
+ "matches": full_matches,
307
+ "counts": status_count
308
+ }
309
+
310
+
311
+ @app.post("/matches/{match_id}/respond")
312
+ def respond_match(
313
+ match_id: str,
314
+ data: MatchRespondRequest
315
+ ):
316
+ match_res = (
317
+ supabase.table("matches")
318
+ .select("*")
319
+ .eq("id", match_id)
320
+ .execute()
321
+ )
322
+
323
+ if not match_res.data:
324
+ raise HTTPException(status_code=404, detail="Match not found")
325
+
326
+ user_id = data.user_id
327
+ action = data.action
328
+ message = data.message
329
+ match_data = match_res.data[0]
330
+
331
+ if match_data["mentor_id"] != user_id:
332
+ raise HTTPException(status_code=403, detail="Unauthorized action")
333
+
334
+ new_status = "accepted" if action == "accept" else "rejected"
335
+ update_data = {
336
+ "status": new_status,
337
+ "message": message or None,
338
+ "accepted_at": None,
339
+ }
340
+
341
+ if action == "accept":
342
+ update_data["accepted_at"] = datetime.utcnow().isoformat()
343
+
344
+ update_res = (
345
+ supabase.table("matches")
346
+ .update(update_data)
347
+ .eq("id", match_id)
348
+ .execute()
349
+ )
350
+
351
+ updated_match = update_res.data[0]
352
+
353
+ points_earned = 50 if action == "accept" else 0
354
+
355
+ return {
356
+ "success": True,
357
+ "match": {
358
+ "id": updated_match["id"],
359
+ "status": updated_match["status"],
360
+ "acceptedAt": updated_match["accepted_at"],
361
+ },
362
+ "pointsEarned": points_earned,
363
+ "message": (
364
+ "Match aceptado! Ahora pueden chatear y agendar sesiones."
365
+ if action == "accept"
366
+ else "Has rechazado la solicitud."
367
+ )
368
+ }
app/models.py CHANGED
@@ -1,8 +1,25 @@
1
  from pydantic import BaseModel, EmailStr
 
2
 
3
  class EmailRequest(BaseModel):
4
  email: EmailStr
5
 
6
  class VerifyRequest(BaseModel):
7
  email: EmailStr
8
- code: str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from pydantic import BaseModel, EmailStr
2
+ from typing import Optional
3
 
4
  class EmailRequest(BaseModel):
5
  email: EmailStr
6
 
7
  class VerifyRequest(BaseModel):
8
  email: EmailStr
9
+ code: str
10
+
11
+ class UserRequest(BaseModel):
12
+ user_id: int
13
+
14
+ class MatchRequest(BaseModel):
15
+ user_id: int
16
+ candidate_id: int
17
+ candidate_name: str
18
+ message: Optional[str] = None
19
+ match_type: Optional[str] = 'MENTOR'
20
+ compatibility_score: Optional[int] = 0
21
+
22
+ class MatchRespondRequest(BaseModel):
23
+ user_id: int
24
+ action: str
25
+ message: Optional[str] = None
app/services.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sentence_transformers import SentenceTransformer, util
2
+
3
+ # Embedder instance and weights for different criteria
4
+ embedder = SentenceTransformer("intfloat/multilingual-e5-small")
5
+ WEIGHTS = {
6
+ "weakness_support": 3.0,
7
+ "interest_semantic": 2.0,
8
+ "future_roles_semantic": 1.5,
9
+ "study_style": 1.2,
10
+ "bio_semantic": 1.0,
11
+ "career_match": 0.8,
12
+ "semester_advantage": 0.5,
13
+ }
14
+
15
+
16
+ # Get functions
17
+ def get_mentors(user_id: int, supabase: any):
18
+ response = (
19
+ supabase.table("user_grades")
20
+ .select("*, users!inner(*, user_profiles(*))")
21
+ .eq("status", "approved")
22
+ .neq("user_id", user_id)
23
+ .execute()
24
+ )
25
+
26
+ mentors = []
27
+ for record in response.data:
28
+ user = record['users']
29
+ user_profile = user['user_profiles']
30
+
31
+ mentor = {
32
+ 'coursename': record['coursename'],
33
+ 'grade': record['grade'],
34
+ 'id': f"uuid-{user['id']}",
35
+ 'firstName': user['firstname'],
36
+ 'lastName': user['lastname'],
37
+ 'career': user['career'],
38
+ 'semester': user['semester'],
39
+ 'profileimage': user['profileimage'],
40
+ 'bio': user_profile['bio'],
41
+ 'strengths': user_profile['strengths'],
42
+ 'futureroles': user_profile['futureroles'],
43
+ 'studystyle': user_profile['studystyle'],
44
+ 'availabletimes': user_profile['availabletimes'],
45
+ 'careerinterests': user_profile['careerinterests']
46
+ }
47
+
48
+ mentors.append(mentor)
49
+
50
+ grouped = {}
51
+
52
+ for m in mentors:
53
+ name = m["firstName"]
54
+
55
+ if name not in grouped:
56
+ grouped[name] = {
57
+ "id": m["id"],
58
+ "firstName": m["firstName"],
59
+ "lastName": m["lastName"],
60
+ "career": m["career"],
61
+ "semester": m["semester"],
62
+ "profileImage": m["profileimage"],
63
+ "bio": m["bio"],
64
+ "strengths": m["strengths"],
65
+ "futureroles": m["futureroles"],
66
+ "studystyle": m["studystyle"],
67
+ "availabletimes": m["availabletimes"],
68
+ "careerinterests": m["careerinterests"],
69
+ "courses": []
70
+ }
71
+
72
+ grouped[name]["courses"].append({
73
+ "coursename": m["coursename"],
74
+ "grade": m["grade"]
75
+ })
76
+
77
+ return {"mentors": list(grouped.values())}
78
+
79
+ def get_user_profile(user_id: int, supabase: any):
80
+ response = (
81
+ supabase.table("users")
82
+ .select("*, user_profiles(*)")
83
+ .eq("id", user_id)
84
+ .execute()
85
+ )
86
+
87
+ user = response.data[0]
88
+
89
+ return {
90
+ 'career': user['career'],
91
+ 'semester': user['semester'],
92
+ 'bio': user['user_profiles']['bio'],
93
+ 'weaknesses': user['user_profiles']['weaknesses'],
94
+ 'futureroles': user['user_profiles']['futureroles'],
95
+ 'studystyle': user['user_profiles']['studystyle'],
96
+ 'availabletimes': user['user_profiles']['availabletimes'],
97
+ 'careerinterests': user['user_profiles']['careerinterests']
98
+ }
99
+
100
+ def extract_common_interests(user, mentor):
101
+ ui = set(user.get("careerinterests", []))
102
+ mi = set(mentor.get("careerinterests", []))
103
+ return list(ui & mi)
104
+
105
+
106
+ # Similarity functions
107
+ def text_list_similarity(list1, list2):
108
+ if not list1 or not list2:
109
+ return 0.0
110
+
111
+ sims = []
112
+ for t1 in list1:
113
+ emb1 = embedder.encode(t1, normalize_embeddings=True)
114
+ for t2 in list2:
115
+ emb2 = embedder.encode(t2, normalize_embeddings=True)
116
+ sims.append(float(util.cos_sim(emb1, emb2)))
117
+
118
+ if not sims:
119
+ return 0.0
120
+
121
+ # Return average similarity
122
+ return sum(sims) / len(sims)
123
+
124
+ def compute_embedding_similarity(text1, text2):
125
+ emb1 = embedder.encode(text1, normalize_embeddings=True)
126
+ emb2 = embedder.encode(text2, normalize_embeddings=True)
127
+ return float(util.cos_sim(emb1, emb2))
128
+
129
+
130
+ # Matching function
131
+ def match_user_to_mentors(user, mentors):
132
+ results = []
133
+
134
+ for mentor in mentors:
135
+ score = 0
136
+ reasons = []
137
+
138
+ # ------------------------------------------------------
139
+ # 1. Weakness support
140
+ # ------------------------------------------------------
141
+ if "weaknesses" in user and "courses" in mentor:
142
+ mentor_courses = {c["coursename"].lower(): c["grade"] for c in mentor["courses"]}
143
+
144
+ for weak in user["weaknesses"]:
145
+ w = weak.lower()
146
+ if w in mentor_courses and mentor_courses[w] >= 15:
147
+ score += WEIGHTS["weakness_support"]
148
+ reasons.append(f"El mentor tiene buena calificación en {weak}.")
149
+
150
+ # ------------------------------------------------------
151
+ # 2. Interest semantic
152
+ # ------------------------------------------------------
153
+ interest_sim = text_list_similarity(
154
+ user.get("careerinterests", []),
155
+ mentor.get("careerinterests", [])
156
+ )
157
+ if interest_sim > 0.3:
158
+ score += interest_sim * WEIGHTS["interest_semantic"]
159
+ reasons.append(f"Alta similitud semántica en intereses ({interest_sim:.2f}).")
160
+
161
+ # ------------------------------------------------------
162
+ # 3. Future roles semantic
163
+ # ------------------------------------------------------
164
+ roles_sim = text_list_similarity(
165
+ user.get("futureroles", []),
166
+ mentor.get("futureroles", [])
167
+ )
168
+ if roles_sim > 0.3:
169
+ score += roles_sim * WEIGHTS["future_roles_semantic"]
170
+ reasons.append(f"Similitud en roles futuros ({roles_sim:.2f}).")
171
+
172
+ # ------------------------------------------------------
173
+ # 4. Study style
174
+ # ------------------------------------------------------
175
+ if user["studystyle"] == mentor["studystyle"]:
176
+ score += WEIGHTS["study_style"]
177
+ reasons.append(f"Ambos tienen un estilo de estudio similar: {user['studystyle']}.")
178
+
179
+ # ------------------------------------------------------
180
+ # 5. Career match
181
+ # ------------------------------------------------------
182
+ if user["career"] == mentor["career"]:
183
+ score += WEIGHTS["career_match"]
184
+
185
+ # ------------------------------------------------------
186
+ # 6. Semester advantage
187
+ # ------------------------------------------------------
188
+ if mentor["semester"] > user["semester"]:
189
+ score += WEIGHTS["semester_advantage"]
190
+
191
+ # ------------------------------------------------------
192
+ # 7. Bio semantic
193
+ # ------------------------------------------------------
194
+ sim = compute_embedding_similarity(user["bio"], mentor["bio"])
195
+ score += sim * WEIGHTS["bio_semantic"]
196
+
197
+ # ------------------------------------------------------
198
+ # Save result
199
+ # ------------------------------------------------------
200
+ results.append({
201
+ "mentor": mentor,
202
+ "compatibilityScore": round(score * 10, 2),
203
+ "matchReasons": reasons
204
+ })
205
+
206
+ results.sort(key=lambda x: x["compatibilityScore"], reverse=True)
207
+
208
+ return {
209
+ "candidates": results,
210
+ "total": len(results)
211
+ }
212
+
213
+ def format_match_result(raw, user):
214
+ mentor = raw["mentor"]
215
+
216
+ return {
217
+ "id": mentor.get("id", None), # si no tienes id aún puedes dejar None
218
+ "user": {
219
+ "id": mentor.get("id", None),
220
+ "firstName": mentor["firstName"],
221
+ "lastName": mentor["lastName"][0] + '.',
222
+ "career": mentor["career"],
223
+ "semester": mentor["semester"],
224
+ "profileImage": mentor.get("profileImage", None),
225
+ "bio": mentor["bio"]
226
+ },
227
+ "compatibilityScore": raw["compatibilityScore"],
228
+ "matchType": "MENTOR",
229
+ "matchReasons": raw["matchReasons"],
230
+ "commonInterests": extract_common_interests(user, mentor),
231
+ "mentorStats": {
232
+ "successRate": mentor.get("successRate", 0),
233
+ "avgRating": mentor.get("avgRating", 0),
234
+ "totalSessions": mentor.get("totalSessions", 0)
235
+ }
236
+ }
237
+
238
+ def get_candidates(user_id: int, limit: int, offset: int, supabase):
239
+ user = get_user_profile(user_id, supabase)
240
+ mentors_data = get_mentors(user_id, supabase)
241
+ mentors = mentors_data["mentors"]
242
+
243
+ # Matching
244
+ match_results = match_user_to_mentors(user, mentors)
245
+ candidates = match_results["candidates"]
246
+
247
+ total = len(candidates)
248
+
249
+ # Pagination
250
+ sliced = candidates[offset : offset + limit]
251
+
252
+ # Final formatting
253
+ formatted = [format_match_result(c, user) for c in sliced]
254
+
255
+ return {
256
+ "candidates": formatted,
257
+ "pagination": {
258
+ "total": total,
259
+ "limit": limit,
260
+ "offset": offset,
261
+ "hasMore": offset + limit < total
262
+ }
263
+ }
requirements.txt CHANGED
@@ -35,3 +35,9 @@ email-validator
35
  # 📨 Optional: Email Templates (HTML)
36
  # -----------------------------
37
  jinja2
 
 
 
 
 
 
 
35
  # 📨 Optional: Email Templates (HTML)
36
  # -----------------------------
37
  jinja2
38
+
39
+ # -----------------------------
40
+ # 🤖 Artificial Intelligence Integration
41
+ # -----------------------------
42
+ torch
43
+ sentence-transformers