Spaces:
Sleeping
Sleeping
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
|
| 59 |
# ---------------------------------------------------------------------
|
| 60 |
|
| 61 |
-
class
|
| 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:
|
| 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:
|
| 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 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 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 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
except Exception as e:
|
| 241 |
-
print(f"❌
|
| 242 |
-
|
| 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={
|
| 146 |
-
<ConfigSlider label="
|
| 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:
|
| 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 |
-
|
| 197 |
skills,
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
}));
|
| 217 |
|
| 218 |
setApplicants(formattedData);
|
|
@@ -279,7 +295,27 @@ export default function AdminSortingPage() {
|
|
| 279 |
|
| 280 |
// --- SORTING & FILTERING ---
|
| 281 |
const filteredApplicants = useMemo(() => {
|
| 282 |
-
return applicants.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ? '
|
| 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' }}>
|