Saandraahh commited on
Commit
3535722
·
1 Parent(s): 35709fb

feat: implement dynamic scoring, webhooks, and resume download

Browse files
backend/api.py CHANGED
@@ -55,10 +55,10 @@ async def process_resume_endpoint(request: ResumeRequest):
55
  raise HTTPException(status_code=500, detail=str(e))
56
 
57
  # ---------------------------------------------------------------------
58
- # WEBHOOK ENDPOINT (Called by Supabase)
59
  # ---------------------------------------------------------------------
60
 
61
- class StorageEventRequest(BaseModel):
62
  type: str
63
  table: str
64
  record: Dict[str, Any]
@@ -66,7 +66,7 @@ class StorageEventRequest(BaseModel):
66
  old_record: Optional[Dict[str, Any]] = None
67
 
68
  @app.post("/webhook/storage")
69
- async def storage_webhook(request: StorageEventRequest):
70
  """
71
  Handles Database Webhooks from Supabase (storage.objects insert).
72
  """
@@ -105,7 +105,7 @@ async def storage_webhook(request: StorageEventRequest):
105
 
106
 
107
  @app.post("/webhook/jobs")
108
- async def jobs_webhook(request: StorageEventRequest):
109
  """
110
  Handles Database Webhooks from Supabase (jobs table UPDATE/INSERT).
111
  """
@@ -157,6 +157,130 @@ async def analyze_ats_endpoint(
157
  # CANDIDATE ANALYSIS ENDPOINT
158
  # ---------------------------------------------------------------------
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  class AnalysisRequest(BaseModel):
161
  candidate_id: str
162
  job_id: Optional[str] = None
@@ -168,77 +292,43 @@ async def analyze_candidate_endpoint(request: AnalysisRequest):
168
  """
169
  print(f"🔬 Analyzing candidate {request.candidate_id} for job {request.job_id}")
170
 
 
 
 
171
  try:
172
- # 1. Fetch Candidate Data
173
- prof_resp = client.table("profiles").select("*").eq("id", request.candidate_id).execute()
174
- if not prof_resp.data:
175
- raise HTTPException(status_code=404, detail="Candidate not found")
176
-
177
- profile = prof_resp.data[0]
178
-
179
- # 2. Fetch Job Data
180
- job_description = ""
181
- job_skills = []
182
-
183
- if request.job_id:
184
- job_resp = client.table("jobs").select("*").eq("id", request.job_id).execute()
185
- if job_resp.data:
186
- job = job_resp.data[0]
187
- job_description = job.get("description") or ""
188
- # Assume job.skills is a list or comma-separated string
189
- raw_job_skills = job.get("skills") if job.get("skills") else job.get("technical_skills")
190
- if isinstance(raw_job_skills, str):
191
- job_skills = [s.strip() for s in raw_job_skills.split(",") if s.strip()]
192
- elif isinstance(raw_job_skills, list):
193
- job_skills = raw_job_skills
194
- else:
195
- job_skills = []
196
-
197
- # 3. Prepare Profile Skills
198
- profile_skills = []
199
- raw_skills = profile.get("skills") or []
200
- if isinstance(raw_skills, str):
201
- profile_skills = [s.strip() for s in raw_skills.split(",") if s.strip()]
202
- else:
203
- profile_skills = raw_skills
204
-
205
- raw_tech_skills = profile.get("technical_skills") or []
206
- if isinstance(raw_tech_skills, str):
207
- profile_skills.extend([s.strip() for s in raw_tech_skills.split(",") if s.strip()])
208
- else:
209
- profile_skills.extend(raw_tech_skills)
210
-
211
- # 4. Generate AI Insights (Summary, Strengths, Weaknesses)
212
- profile_summary = f"""
213
- Role: {profile.get('role')}
214
- Headline: {profile.get('headline')}
215
- Summary: {profile.get('summary')}
216
- Experience: {profile.get('experience_years')} years
217
- Work Experience: {profile.get('work_experience')}
218
- Education: {profile.get('education')}
219
- Skills: {", ".join(profile_skills)}
220
- """
221
-
222
- ai_insights = generate_ai_analysis(profile_summary, job_description)
223
-
224
- # 5. Identify Missing Skills (Semantic)
225
- missing = []
226
- if job_skills:
227
- missing = identify_missing_skills(job_skills, profile_skills)
228
 
229
- return {
230
- "status": "success",
231
- "data": {
232
- "summary": ai_insights.get("summary"),
233
- "strengths": ai_insights.get("strengths"),
234
- "weaknesses": ai_insights.get("weaknesses"),
235
- "score": ai_insights.get("score") or 0,
236
- "missing_skills": missing
237
- }
238
- }
 
 
 
 
 
 
 
 
 
 
239
 
 
 
 
 
240
  except Exception as e:
241
- print(f"❌ Analysis failed: {e}")
242
- raise HTTPException(status_code=500, detail=str(e))
243
 
244
  # Run with: uvicorn api:app --reload
 
55
  raise HTTPException(status_code=500, detail=str(e))
56
 
57
  # ---------------------------------------------------------------------
58
+ # WEBHOOK ENDPOINTS (Called by Supabase)
59
  # ---------------------------------------------------------------------
60
 
61
+ class WebhookRequest(BaseModel):
62
  type: str
63
  table: str
64
  record: Dict[str, Any]
 
66
  old_record: Optional[Dict[str, Any]] = None
67
 
68
  @app.post("/webhook/storage")
69
+ async def storage_webhook(request: WebhookRequest):
70
  """
71
  Handles Database Webhooks from Supabase (storage.objects insert).
72
  """
 
105
 
106
 
107
  @app.post("/webhook/jobs")
108
+ async def jobs_webhook(request: WebhookRequest):
109
  """
110
  Handles Database Webhooks from Supabase (jobs table UPDATE/INSERT).
111
  """
 
157
  # CANDIDATE ANALYSIS ENDPOINT
158
  # ---------------------------------------------------------------------
159
 
160
+ # ---------------------------------------------------------------------
161
+ # CORE ANALYSIS LOGIC
162
+ # ---------------------------------------------------------------------
163
+
164
+ async def perform_candidate_analysis(candidate_id: str, job_id: str, force_refresh: bool = False):
165
+ """
166
+ Shared logic to analyze a candidate for a job.
167
+ Checks for cached results first unless force_refresh is True.
168
+ """
169
+ # 0. Check for existing analysis in applications table
170
+ if not force_refresh:
171
+ app_resp = client.table("applications").select("ai_summary, ai_insights, AI_score").eq("user_id", candidate_id).eq("job_id", job_id).execute()
172
+
173
+ if app_resp.data and app_resp.data[0].get("ai_summary"):
174
+ app_data = app_resp.data[0]
175
+ insights = app_data.get("ai_insights") or {}
176
+
177
+ # Use cached missing_skills if available
178
+ if "missing_skills" in insights:
179
+ print(f"✅ Found fully cached analysis for {candidate_id}")
180
+ return {
181
+ "summary": app_data.get("ai_summary"),
182
+ "strengths": insights.get("strengths") or [],
183
+ "weaknesses": insights.get("weaknesses") or [],
184
+ "score": app_data.get("AI_score") or 0,
185
+ "missing_skills": insights.get("missing_skills") or []
186
+ }
187
+
188
+ # 1. Fetch Candidate Data
189
+ prof_resp = client.table("profiles").select("*").eq("id", candidate_id).execute()
190
+ if not prof_resp.data:
191
+ raise HTTPException(status_code=404, detail="Candidate not found")
192
+
193
+ profile = prof_resp.data[0]
194
+
195
+ # 2. Fetch Job Data
196
+ job_description = ""
197
+ job_skills = []
198
+
199
+ if job_id:
200
+ job_resp = client.table("jobs").select("*").eq("id", job_id).execute()
201
+ if job_resp.data:
202
+ job = job_resp.data[0]
203
+ job_description = job.get("description") or ""
204
+ raw_job_skills = job.get("skills") if job.get("skills") else job.get("technical_skills")
205
+ if isinstance(raw_job_skills, str):
206
+ job_skills = [s.strip() for s in raw_job_skills.split(",") if s.strip()]
207
+ elif isinstance(raw_job_skills, list):
208
+ job_skills = raw_job_skills
209
+
210
+ # 3. Prepare Profile Skills
211
+ profile_skills = []
212
+ raw_skills = profile.get("skills") or []
213
+ if isinstance(raw_skills, str):
214
+ profile_skills = [s.strip() for s in raw_skills.split(",") if s.strip()]
215
+ else:
216
+ profile_skills = raw_skills
217
+
218
+ raw_tech_skills = profile.get("technical_skills") or []
219
+ if isinstance(raw_tech_skills, str):
220
+ profile_skills.extend([s.strip() for s in raw_tech_skills.split(",") if s.strip()])
221
+ else:
222
+ profile_skills.extend(raw_tech_skills)
223
+
224
+ # 4. Identify Missing Skills (Semantic)
225
+ missing = []
226
+ if job_skills:
227
+ missing = identify_missing_skills(job_skills, profile_skills)
228
+
229
+ # 4a. If we had half-cached data (summary exists but missing_skills don't)
230
+ # We check cache again just in case it was partially filled
231
+ app_resp = client.table("applications").select("ai_summary, ai_insights, AI_score").eq("user_id", candidate_id).eq("job_id", job_id).execute()
232
+ if not force_refresh and app_resp.data and app_resp.data[0].get("ai_summary"):
233
+ app_data = app_resp.data[0]
234
+ insights = app_data.get("ai_insights") or {}
235
+ return {
236
+ "summary": app_data.get("ai_summary"),
237
+ "strengths": insights.get("strengths") or [],
238
+ "weaknesses": insights.get("weaknesses") or [],
239
+ "score": app_data.get("AI_score") or 0,
240
+ "missing_skills": missing
241
+ }
242
+
243
+ # 5. Generate fresh AI Insights
244
+ profile_summary = f"""
245
+ Role: {profile.get('role')}
246
+ Headline: {profile.get('headline')}
247
+ Summary: {profile.get('summary')}
248
+ Experience: {profile.get('experience_years')} years
249
+ Work Experience: {profile.get('work_experience')}
250
+ Education: {profile.get('education')}
251
+ Skills: {", ".join(profile_skills)}
252
+ """
253
+
254
+ ai_insights = generate_ai_analysis(profile_summary, job_description)
255
+
256
+ # 6. Persist to Database
257
+ try:
258
+ data_to_save = {
259
+ "ai_summary": ai_insights.get("summary"),
260
+ "ai_insights": {
261
+ "strengths": ai_insights.get("strengths") or [],
262
+ "weaknesses": ai_insights.get("weaknesses") or [],
263
+ "missing_skills": missing
264
+ },
265
+ "AI_score": ai_insights.get("score") or 0
266
+ }
267
+ client.table("applications").update(data_to_save).eq("user_id", candidate_id).eq("job_id", job_id).execute()
268
+ print(f"💾 Persisted AI analysis for candidate {candidate_id}")
269
+ except Exception as db_err:
270
+ print(f"⚠️ Failed to persist AI analysis: {db_err}")
271
+
272
+ return {
273
+ "summary": ai_insights.get("summary"),
274
+ "strengths": ai_insights.get("strengths"),
275
+ "weaknesses": ai_insights.get("weaknesses"),
276
+ "score": ai_insights.get("score") or 0,
277
+ "missing_skills": missing
278
+ }
279
+
280
+ # ---------------------------------------------------------------------
281
+ # CANDIDATE ANALYSIS ENDPOINT
282
+ # ---------------------------------------------------------------------
283
+
284
  class AnalysisRequest(BaseModel):
285
  candidate_id: str
286
  job_id: Optional[str] = None
 
292
  """
293
  print(f"🔬 Analyzing candidate {request.candidate_id} for job {request.job_id}")
294
 
295
+ if not request.job_id:
296
+ raise HTTPException(status_code=400, detail="job_id is required for analysis")
297
+
298
  try:
299
+ result = await perform_candidate_analysis(request.candidate_id, request.job_id)
300
+ return {"status": "success", "data": result}
301
+ except Exception as e:
302
+ print(f" Analysis endpoint failed: {e}")
303
+ raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
 
305
+ @app.post("/webhook/applications")
306
+ async def applications_webhook(request: WebhookRequest):
307
+ """
308
+ Handles Database Webhooks from Supabase (applications table UPDATE/INSERT).
309
+ """
310
+ print(f"🔔 Webhook received: {request.type} on {request.table}")
311
+
312
+ if request.table != "applications":
313
+ return {"status": "ignored", "reason": "wrong table"}
314
+
315
+ record = request.record
316
+ # Avoid infinite loop: if this update already contains ai_summary, ignore it
317
+ if request.type == "UPDATE" and record.get("ai_summary"):
318
+ return {"status": "skipped", "reason": "already analyzed"}
319
+
320
+ candidate_id = record.get("user_id")
321
+ job_id = record.get("job_id")
322
+
323
+ if not candidate_id or not job_id:
324
+ return {"status": "error", "message": "missing ids"}
325
 
326
+ print(f"▶️ Auto-triggering AI analysis for {candidate_id} / {job_id}")
327
+ try:
328
+ await perform_candidate_analysis(candidate_id, job_id)
329
+ return {"status": "success"}
330
  except Exception as e:
331
+ print(f"❌ Webhook analysis failed: {e}")
332
+ return {"status": "error", "message": str(e)}
333
 
334
  # Run with: uvicorn api:app --reload
src/components/Admin/AdminSortingPage.jsx CHANGED
@@ -142,8 +142,8 @@ const ScoringPanel = ({ config, setConfig, onReset, onClose }) => {
142
  <ConfigSlider label="Experience Weight" value={config.experienceWeight} min={1} max={10} onChangeKey="experienceWeight" />
143
  </div>
144
  <div>
145
- <ConfigSlider label="Certification Bonus" value={config.certBonus} min={0} max={50} onChangeKey="certBonus" />
146
- <ConfigSlider label="Languages Weight" value={config.langWeight} min={1} max={10} onChangeKey="langWeight" />
147
  </div>
148
  </div>
149
  <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}>
@@ -173,7 +173,7 @@ export default function AdminSortingPage() {
173
  const [filters, setFilters] = useState(initialFilters);
174
 
175
  // Scoring
176
- const defaultScoring = { skillsWeight: 2, experienceWeight: 5, certBonus: 15, langWeight: 1 };
177
  const [scoringConfig, setScoringConfig] = useState(defaultScoring);
178
 
179
  //candidate overview
@@ -193,9 +193,15 @@ export default function AdminSortingPage() {
193
  id,
194
  created_at,
195
  status,
196
- score: match_score,
197
  skills,
198
- profiles ( id, full_name, email, avatar_url, experience_years ),
 
 
 
 
 
 
199
  jobs ( id, title )
200
  `);
201
 
@@ -212,7 +218,17 @@ export default function AdminSortingPage() {
212
  experience: parseInt(app.profiles?.experience_years) || 'Fresher',
213
  skills: app.skills || [],
214
  status: app.status,
215
- score: app.score || 0
 
 
 
 
 
 
 
 
 
 
216
  }));
217
 
218
  setApplicants(formattedData);
@@ -279,7 +295,27 @@ export default function AdminSortingPage() {
279
 
280
  // --- SORTING & FILTERING ---
281
  const filteredApplicants = useMemo(() => {
282
- return applicants.filter(app => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) || (app.jobTitle || '').toLowerCase().includes(searchQuery.toLowerCase());
284
  const matchesStatus = filters.status === 'All' || app.status === filters.status;
285
  const matchesScore = (app.score || 0) >= filters.minScore;
@@ -294,7 +330,7 @@ export default function AdminSortingPage() {
294
  if (filters.sortBy === 'Name') return a.name.localeCompare(b.name);
295
  return 0;
296
  });
297
- }, [searchQuery, filters, applicants]);
298
 
299
  // Check if every single selected person is already 'Accepted'
300
  const allSelectedAreApproved = selectedIds.length > 0 && selectedIds.every(id => {
 
142
  <ConfigSlider label="Experience Weight" value={config.experienceWeight} min={1} max={10} onChangeKey="experienceWeight" />
143
  </div>
144
  <div>
145
+ <ConfigSlider label="Certification Bonus" value={config.certBonus} min={1} max={10} onChangeKey="certBonus" />
146
+ <ConfigSlider label="Projects Weight" value={config.projectsWeight} min={1} max={10} onChangeKey="projectsWeight" />
147
  </div>
148
  </div>
149
  <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}>
 
173
  const [filters, setFilters] = useState(initialFilters);
174
 
175
  // Scoring
176
+ const defaultScoring = { skillsWeight: 2, experienceWeight: 5, certBonus: 3, projectsWeight: 3 };
177
  const [scoringConfig, setScoringConfig] = useState(defaultScoring);
178
 
179
  //candidate overview
 
193
  id,
194
  created_at,
195
  status,
196
+ match_score,
197
  skills,
198
+ skills_match,
199
+ technical_skills_match,
200
+ work_experience_match,
201
+ education_match,
202
+ certifications_match,
203
+ project_match,
204
+ profiles ( id, full_name, email, avatar_url, experience_years, languages, resume_url ),
205
  jobs ( id, title )
206
  `);
207
 
 
218
  experience: parseInt(app.profiles?.experience_years) || 'Fresher',
219
  skills: app.skills || [],
220
  status: app.status,
221
+ resumeUrl: app.profiles?.resume_url,
222
+ dbScore: app.match_score || 0,
223
+ scores: {
224
+ skills: app.skills_match || 0,
225
+ technical: app.technical_skills_match || 0,
226
+ experience: app.work_experience_match || 0,
227
+ certifications: app.certifications_match || 0,
228
+ languages: 0,
229
+ education: app.education_match || 0,
230
+ projects: app.project_match || 0
231
+ }
232
  }));
233
 
234
  setApplicants(formattedData);
 
295
 
296
  // --- SORTING & FILTERING ---
297
  const filteredApplicants = useMemo(() => {
298
+ return applicants.map(app => {
299
+ // Dynamic Score Calculation
300
+ const { skillsWeight, experienceWeight, certBonus, projectsWeight } = scoringConfig;
301
+
302
+ // Using a weighted average formula
303
+ // Denominator: weights + fixed education(2)
304
+ const totalWeight = skillsWeight + experienceWeight + projectsWeight + certBonus;
305
+
306
+ const dynamicScore = Math.round(
307
+ (Math.max(app.scores.skills, app.scores.technical) * skillsWeight +
308
+ app.scores.experience * experienceWeight +
309
+ app.scores.education * 2 + // Education as a 2x base
310
+ app.scores.projects * projectsWeight + // Projects now dynamic
311
+ app.scores.certifications * certBonus) / (totalWeight + 2)
312
+ );
313
+
314
+ // If calculation leads to 0 but DB has a score, and weights are default, show DB score
315
+ const finalScore = (dynamicScore === 0 && app.dbScore > 0) ? app.dbScore : dynamicScore;
316
+
317
+ return { ...app, score: finalScore };
318
+ }).filter(app => {
319
  const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) || (app.jobTitle || '').toLowerCase().includes(searchQuery.toLowerCase());
320
  const matchesStatus = filters.status === 'All' || app.status === filters.status;
321
  const matchesScore = (app.score || 0) >= filters.minScore;
 
330
  if (filters.sortBy === 'Name') return a.name.localeCompare(b.name);
331
  return 0;
332
  });
333
+ }, [searchQuery, filters, applicants, scoringConfig]);
334
 
335
  // Check if every single selected person is already 'Accepted'
336
  const allSelectedAreApproved = selectedIds.length > 0 && selectedIds.every(id => {
src/components/Adminfront/TopPerformers.jsx CHANGED
@@ -48,7 +48,7 @@ export default function TopPerformers() {
48
  .select(`
49
  id,
50
  experience,
51
- user_id:profiles(id, full_name, avatar_url, summary),
52
  job_id:jobs(id, title),
53
  match_score
54
  `)
@@ -64,6 +64,7 @@ export default function TopPerformers() {
64
  jobId: item.job_id?.id,
65
  name: item.user_id?.full_name || 'Candidate',
66
  avatar: item.user_id?.avatar_url,
 
67
  jobTitle: item.job_id?.title || 'Applicant',
68
  experience: item.experience,
69
  summary: item.user_id?.summary,
 
48
  .select(`
49
  id,
50
  experience,
51
+ user_id:profiles(id, full_name, avatar_url, summary, resume_url),
52
  job_id:jobs(id, title),
53
  match_score
54
  `)
 
64
  jobId: item.job_id?.id,
65
  name: item.user_id?.full_name || 'Candidate',
66
  avatar: item.user_id?.avatar_url,
67
+ resumeUrl: item.user_id?.resume_url,
68
  jobTitle: item.job_id?.title || 'Applicant',
69
  experience: item.experience,
70
  summary: item.user_id?.summary,
src/components/CandidateDrawer.jsx CHANGED
@@ -132,7 +132,7 @@ const CandidateDrawer = ({ isOpen, onClose, candidate }) => {
132
 
133
  <div style={{ marginBottom: '2rem', padding: '1rem', backgroundColor: 'rgba(239, 68, 68, 0.05)', borderRadius: '0.75rem', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
134
  <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
135
- <span style={{ fontWeight: 'bold', color: '#EF4444' }}>{analysis?.score !== undefined ? 'Gemini AI Score' : 'Semantic Match Score'}</span>
136
  <span style={{ fontWeight: 'bold', color: 'white' }}>{analysis?.score ?? (candidate.score || candidate.matchScore || 0)}%</span>
137
  </div>
138
  <div style={{ width: '100%', height: '6px', backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: '3px' }}>
 
132
 
133
  <div style={{ marginBottom: '2rem', padding: '1rem', backgroundColor: 'rgba(239, 68, 68, 0.05)', borderRadius: '0.75rem', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
134
  <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
135
+ <span style={{ fontWeight: 'bold', color: '#EF4444' }}>{analysis?.score !== undefined ? 'AI Score' : 'Semantic Match Score'}</span>
136
  <span style={{ fontWeight: 'bold', color: 'white' }}>{analysis?.score ?? (candidate.score || candidate.matchScore || 0)}%</span>
137
  </div>
138
  <div style={{ width: '100%', height: '6px', backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: '3px' }}>