Spaces:
Sleeping
Sleeping
Commit ·
ad01d65
1
Parent(s): 01c8f1f
Pushing to IRIS_latest repository
Browse files- backend/api.py +14 -4
- backend/src/embeddings/debug_embedding_storage.py +6 -3
- backend/src/embeddings/debug_match_score.py +75 -0
- backend/src/embeddings/job_embed.py +1 -1
- backend/src/embeddings/process_all_profiles.py +2 -1
- backend/src/extraction/job_extractor.py +8 -0
- backend/src/matching/similarity.py +95 -0
- backend/supabase_ingest.py +14 -1
- backend/update_applications_table.sql +9 -0
- src/components/CandidateDrawer.jsx +32 -4
- src/components/Icons.jsx +26 -26
backend/api.py
CHANGED
|
@@ -13,6 +13,7 @@ from supabase_ingest import process_resume
|
|
| 13 |
from src.extraction.job_extractor import process_single_job
|
| 14 |
from src.services.ats_service import analyze_ats_compatibility
|
| 15 |
from src.services.analysis import identify_missing_skills, generate_ai_analysis
|
|
|
|
| 16 |
from typing import Dict, Any, Optional, List
|
| 17 |
|
| 18 |
|
|
@@ -182,6 +183,8 @@ async def perform_candidate_analysis(candidate_id: str, job_id: str, force_refre
|
|
| 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 |
|
|
@@ -190,6 +193,9 @@ async def perform_candidate_analysis(candidate_id: str, job_id: str, force_refre
|
|
| 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
|
|
@@ -234,9 +240,10 @@ async def perform_candidate_analysis(candidate_id: str, job_id: str, force_refre
|
|
| 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 |
|
|
@@ -258,11 +265,12 @@ async def perform_candidate_analysis(candidate_id: str, job_id: str, force_refre
|
|
| 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}")
|
|
@@ -274,6 +282,8 @@ async def perform_candidate_analysis(candidate_id: str, job_id: str, force_refre
|
|
| 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 |
|
|
|
|
| 13 |
from src.extraction.job_extractor import process_single_job
|
| 14 |
from src.services.ats_service import analyze_ats_compatibility
|
| 15 |
from src.services.analysis import identify_missing_skills, generate_ai_analysis
|
| 16 |
+
from src.matching.similarity import calculate_granular_match_score
|
| 17 |
from typing import Dict, Any, Optional, List
|
| 18 |
|
| 19 |
|
|
|
|
| 183 |
"strengths": insights.get("strengths") or [],
|
| 184 |
"weaknesses": insights.get("weaknesses") or [],
|
| 185 |
"score": app_data.get("AI_score") or 0,
|
| 186 |
+
"semantic_score": app_data.get("semantic_score") or 0,
|
| 187 |
+
"score_breakdown": insights.get("score_breakdown") or {},
|
| 188 |
"missing_skills": insights.get("missing_skills") or []
|
| 189 |
}
|
| 190 |
|
|
|
|
| 193 |
if not prof_resp.data:
|
| 194 |
raise HTTPException(status_code=404, detail="Candidate not found")
|
| 195 |
|
| 196 |
+
# 0.5 Granular Match Score (Vector Similarity)
|
| 197 |
+
semantic_result = await calculate_granular_match_score(client, candidate_id, job_id)
|
| 198 |
+
|
| 199 |
profile = prof_resp.data[0]
|
| 200 |
|
| 201 |
# 2. Fetch Job Data
|
|
|
|
| 240 |
insights = app_data.get("ai_insights") or {}
|
| 241 |
return {
|
| 242 |
"summary": app_data.get("ai_summary"),
|
|
|
|
| 243 |
"weaknesses": insights.get("weaknesses") or [],
|
| 244 |
"score": app_data.get("AI_score") or 0,
|
| 245 |
+
"semantic_score": semantic_result.get("total_score"),
|
| 246 |
+
"score_breakdown": semantic_result.get("breakdown"),
|
| 247 |
"missing_skills": missing
|
| 248 |
}
|
| 249 |
|
|
|
|
| 265 |
data_to_save = {
|
| 266 |
"ai_summary": ai_insights.get("summary"),
|
| 267 |
"ai_insights": {
|
|
|
|
| 268 |
"weaknesses": ai_insights.get("weaknesses") or [],
|
| 269 |
+
"missing_skills": missing,
|
| 270 |
+
"score_breakdown": semantic_result.get("breakdown")
|
| 271 |
},
|
| 272 |
+
"AI_score": ai_insights.get("score") or 0,
|
| 273 |
+
"semantic_score": semantic_result.get("total_score")
|
| 274 |
}
|
| 275 |
client.table("applications").update(data_to_save).eq("user_id", candidate_id).eq("job_id", job_id).execute()
|
| 276 |
print(f"💾 Persisted AI analysis for candidate {candidate_id}")
|
|
|
|
| 282 |
"strengths": ai_insights.get("strengths"),
|
| 283 |
"weaknesses": ai_insights.get("weaknesses"),
|
| 284 |
"score": ai_insights.get("score") or 0,
|
| 285 |
+
"semantic_score": semantic_result.get("total_score"),
|
| 286 |
+
"score_breakdown": semantic_result.get("breakdown"),
|
| 287 |
"missing_skills": missing
|
| 288 |
}
|
| 289 |
|
backend/src/embeddings/debug_embedding_storage.py
CHANGED
|
@@ -6,10 +6,12 @@ import time
|
|
| 6 |
# Add 'backend' directory to path so we can import 'supabase_ingest' directly
|
| 7 |
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
|
| 8 |
|
| 9 |
-
from
|
|
|
|
| 10 |
|
|
|
|
| 11 |
# Mock data
|
| 12 |
-
user_id =
|
| 13 |
extracted_data = {
|
| 14 |
"headline": "Debug Engineer",
|
| 15 |
"summary": "This is a test summary for debugging.",
|
|
@@ -28,7 +30,8 @@ try:
|
|
| 28 |
profile_payload = {
|
| 29 |
"id": user_id,
|
| 30 |
"full_name": "Debug User",
|
| 31 |
-
"email": "
|
|
|
|
| 32 |
"updated_at": "now()",
|
| 33 |
# Add the fields we expect to be there
|
| 34 |
"headline": extracted_data["headline"],
|
|
|
|
| 6 |
# Add 'backend' directory to path so we can import 'supabase_ingest' directly
|
| 7 |
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
|
| 8 |
|
| 9 |
+
from src.embeddings.local_embedder import safe_generate_and_store_embeddings
|
| 10 |
+
from supabase_ingest import client
|
| 11 |
|
| 12 |
+
import uuid
|
| 13 |
# Mock data
|
| 14 |
+
user_id = str(uuid.uuid4())
|
| 15 |
extracted_data = {
|
| 16 |
"headline": "Debug Engineer",
|
| 17 |
"summary": "This is a test summary for debugging.",
|
|
|
|
| 30 |
profile_payload = {
|
| 31 |
"id": user_id,
|
| 32 |
"full_name": "Debug User",
|
| 33 |
+
"email": f"debug_{int(time.time())}@example.com",
|
| 34 |
+
"phone": "+919876543210",
|
| 35 |
"updated_at": "now()",
|
| 36 |
# Add the fields we expect to be there
|
| 37 |
"headline": extracted_data["headline"],
|
backend/src/embeddings/debug_match_score.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import sys
|
| 3 |
+
import os
|
| 4 |
+
import asyncio
|
| 5 |
+
import uuid
|
| 6 |
+
import time
|
| 7 |
+
|
| 8 |
+
# Add root to path
|
| 9 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
|
| 10 |
+
|
| 11 |
+
from supabase_ingest import client
|
| 12 |
+
from src.embeddings.local_embedder import safe_generate_and_store_embeddings
|
| 13 |
+
from src.embeddings.job_embed import safe_generate_and_store_job_embeddings
|
| 14 |
+
from src.extraction.job_extractor import upsert_job_entities
|
| 15 |
+
from src.matching.similarity import calculate_granular_match_score
|
| 16 |
+
|
| 17 |
+
async def test_full_ranking_flow():
|
| 18 |
+
print("🚀 Testing Full Ranking Flow...")
|
| 19 |
+
|
| 20 |
+
# 1. Create Test Candidate
|
| 21 |
+
candidate_id = str(uuid.uuid4())
|
| 22 |
+
print(f"👤 Creating Test Candidate: {candidate_id}")
|
| 23 |
+
profile_payload = {
|
| 24 |
+
"id": candidate_id,
|
| 25 |
+
"full_name": "Ranking Test User",
|
| 26 |
+
"email": f"rank_test_{int(time.time())}@example.com",
|
| 27 |
+
"phone": "+919876543210",
|
| 28 |
+
"skills": "Python, Machine Learning, SQL",
|
| 29 |
+
"technical_skills": "PyTorch, Scikit-learn, PostgreSQL",
|
| 30 |
+
"experience_years": "5",
|
| 31 |
+
"role": "Data Scientist",
|
| 32 |
+
"updated_at": "now()"
|
| 33 |
+
}
|
| 34 |
+
client.table("profiles").upsert(profile_payload).execute()
|
| 35 |
+
|
| 36 |
+
# 2. Create Test Job
|
| 37 |
+
job_id = str(uuid.uuid4())
|
| 38 |
+
print(f"💼 Creating Test Job: {job_id}")
|
| 39 |
+
job_payload = {
|
| 40 |
+
"id": job_id,
|
| 41 |
+
"title": "Senior Data Scientist",
|
| 42 |
+
"description": "Looking for a Data Scientist with Python and AI experience.",
|
| 43 |
+
"updated_at": "now()"
|
| 44 |
+
}
|
| 45 |
+
client.table("jobs").upsert(job_payload).execute()
|
| 46 |
+
|
| 47 |
+
# Manually upsert entities for the job
|
| 48 |
+
job_entities = {
|
| 49 |
+
"skills": ["Python", "Problem Solving"],
|
| 50 |
+
"technical_skills": ["Machine Learning", "PyTorch"],
|
| 51 |
+
"experience": "5+ years of experience in data science.",
|
| 52 |
+
"certifications": []
|
| 53 |
+
}
|
| 54 |
+
upsert_job_entities(client, job_id, "Senior", job_entities)
|
| 55 |
+
|
| 56 |
+
# 3. Generate Embeddings
|
| 57 |
+
print("🧬 Generating Embeddings (On CPU)...")
|
| 58 |
+
safe_generate_and_store_embeddings(client, candidate_id)
|
| 59 |
+
safe_generate_and_store_job_embeddings(client, job_id)
|
| 60 |
+
|
| 61 |
+
# 4. Calculate Match Score
|
| 62 |
+
print("📊 Calculating Granular Match Score...")
|
| 63 |
+
result = await calculate_granular_match_score(client, candidate_id, job_id)
|
| 64 |
+
|
| 65 |
+
print("\n✅ MATCH SCORE RESULT:")
|
| 66 |
+
print(f"Total Score: {result.get('total_score')}%")
|
| 67 |
+
print(f"Breakdown: {result.get('breakdown')}")
|
| 68 |
+
|
| 69 |
+
if result.get('total_score', 0) > 0:
|
| 70 |
+
print("\n🎉 SUCCESS: Granular match score works!")
|
| 71 |
+
else:
|
| 72 |
+
print("\n❌ FAILURE: Total score is 0.")
|
| 73 |
+
|
| 74 |
+
if __name__ == "__main__":
|
| 75 |
+
asyncio.run(test_full_ranking_flow())
|
backend/src/embeddings/job_embed.py
CHANGED
|
@@ -56,7 +56,7 @@ def safe_generate_and_store_job_embeddings(client, job_id: str) -> None:
|
|
| 56 |
print(f"🧬 Generating job embeddings for Job: {job_id}")
|
| 57 |
|
| 58 |
# 1. Fetch job entities
|
| 59 |
-
resp = client.table("
|
| 60 |
.select("*") \
|
| 61 |
.eq("job_id", job_id) \
|
| 62 |
.execute()
|
|
|
|
| 56 |
print(f"🧬 Generating job embeddings for Job: {job_id}")
|
| 57 |
|
| 58 |
# 1. Fetch job entities
|
| 59 |
+
resp = client.table("jobs_entities") \
|
| 60 |
.select("*") \
|
| 61 |
.eq("job_id", job_id) \
|
| 62 |
.execute()
|
backend/src/embeddings/process_all_profiles.py
CHANGED
|
@@ -6,7 +6,8 @@ import time
|
|
| 6 |
# Add backend to path
|
| 7 |
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
|
| 8 |
|
| 9 |
-
from supabase_ingest import client
|
|
|
|
| 10 |
|
| 11 |
def process_all_profiles():
|
| 12 |
print("🔍 Fetching all user IDs from 'profiles' table...")
|
|
|
|
| 6 |
# Add backend to path
|
| 7 |
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
|
| 8 |
|
| 9 |
+
from supabase_ingest import client
|
| 10 |
+
from src.embeddings.local_embedder import safe_generate_and_store_embeddings
|
| 11 |
|
| 12 |
def process_all_profiles():
|
| 13 |
print("🔍 Fetching all user IDs from 'profiles' table...")
|
backend/src/extraction/job_extractor.py
CHANGED
|
@@ -8,6 +8,7 @@ from google import genai
|
|
| 8 |
from google.genai import types
|
| 9 |
|
| 10 |
from supabase import create_client
|
|
|
|
| 11 |
|
| 12 |
# ------------------ CONFIGURATION ------------------
|
| 13 |
RAW_DIR = "data/jobs/raw"
|
|
@@ -118,6 +119,13 @@ def upsert_job_entities(sb, job_id: str, experience_level: str, data: Dict[str,
|
|
| 118 |
try:
|
| 119 |
sb.table("jobs_entities").upsert(payload).execute()
|
| 120 |
print(f"✅ Database updated for Job ID: {job_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
except Exception as e:
|
| 122 |
print(f"❌ DB Upsert Error for {job_id}: {e}")
|
| 123 |
|
|
|
|
| 8 |
from google.genai import types
|
| 9 |
|
| 10 |
from supabase import create_client
|
| 11 |
+
from src.embeddings.job_embed import safe_generate_and_store_job_embeddings
|
| 12 |
|
| 13 |
# ------------------ CONFIGURATION ------------------
|
| 14 |
RAW_DIR = "data/jobs/raw"
|
|
|
|
| 119 |
try:
|
| 120 |
sb.table("jobs_entities").upsert(payload).execute()
|
| 121 |
print(f"✅ Database updated for Job ID: {job_id}")
|
| 122 |
+
|
| 123 |
+
# ⚠️ NEW: Generate Embeddings
|
| 124 |
+
try:
|
| 125 |
+
safe_generate_and_store_job_embeddings(sb, job_id)
|
| 126 |
+
except Exception as e:
|
| 127 |
+
print(f"⚠️ Job embedding generation failed (non-critical): {e}")
|
| 128 |
+
|
| 129 |
except Exception as e:
|
| 130 |
print(f"❌ DB Upsert Error for {job_id}: {e}")
|
| 131 |
|
backend/src/matching/similarity.py
CHANGED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import numpy as np
|
| 3 |
+
from typing import Dict, Any, List
|
| 4 |
+
from supabase import Client
|
| 5 |
+
|
| 6 |
+
def cosine_similarity(v1: List[float], v2: List[float]) -> float:
|
| 7 |
+
"""Calculates cosine similarity between two vectors."""
|
| 8 |
+
if not v1 or not v2 or len(v1) != len(v2):
|
| 9 |
+
return 0.0
|
| 10 |
+
|
| 11 |
+
a = np.array(v1)
|
| 12 |
+
b = np.array(v2)
|
| 13 |
+
|
| 14 |
+
# Check if vectors are zero vectors
|
| 15 |
+
if np.all(a == 0) or np.all(b == 0):
|
| 16 |
+
return 0.0
|
| 17 |
+
|
| 18 |
+
dot_product = np.dot(a, b)
|
| 19 |
+
norm_a = np.linalg.norm(a)
|
| 20 |
+
norm_b = np.linalg.norm(b)
|
| 21 |
+
|
| 22 |
+
if norm_a == 0 or norm_b == 0:
|
| 23 |
+
return 0.0
|
| 24 |
+
|
| 25 |
+
return float(dot_product / (norm_a * norm_b))
|
| 26 |
+
|
| 27 |
+
async def calculate_granular_match_score(client: Client, candidate_id: str, job_id: str) -> Dict[str, Any]:
|
| 28 |
+
"""
|
| 29 |
+
Fetches embeddings for candidate and job, calculates entity-wise similarity,
|
| 30 |
+
and returns a weighted total score.
|
| 31 |
+
"""
|
| 32 |
+
print(f"📊 Calculating granular match score for Candidate: {candidate_id}, Job: {job_id}")
|
| 33 |
+
|
| 34 |
+
# 1. Fetch Embeddings
|
| 35 |
+
try:
|
| 36 |
+
profile_resp = client.table("profile_embeddings").select("*").eq("id", candidate_id).execute()
|
| 37 |
+
job_resp = client.table("job_embeddings").select("*").eq("job_id", job_id).execute()
|
| 38 |
+
|
| 39 |
+
if not profile_resp.data:
|
| 40 |
+
print(f"⚠️ No profile embeddings found for {candidate_id}")
|
| 41 |
+
return {"total_score": 0, "breakdown": {}, "error": "Profile embeddings missing"}
|
| 42 |
+
|
| 43 |
+
if not job_resp.data:
|
| 44 |
+
print(f"⚠️ No job embeddings found for {job_id}")
|
| 45 |
+
return {"total_score": 0, "breakdown": {}, "error": "Job embeddings missing"}
|
| 46 |
+
|
| 47 |
+
profile_emb = profile_resp.data[0]
|
| 48 |
+
job_emb = job_resp.data[0]
|
| 49 |
+
|
| 50 |
+
except Exception as e:
|
| 51 |
+
print(f"❌ Database error in match score: {e}")
|
| 52 |
+
return {"total_score": 0, "breakdown": {}, "error": str(e)}
|
| 53 |
+
|
| 54 |
+
# 2. Define Weights
|
| 55 |
+
# These could eventually be user-defined
|
| 56 |
+
WEIGHTS = {
|
| 57 |
+
"skills": 0.35,
|
| 58 |
+
"technical_skills": 0.35,
|
| 59 |
+
"experience": 0.20,
|
| 60 |
+
"certifications": 0.10
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
# 3. Calculate Individual Similarities
|
| 64 |
+
scores = {}
|
| 65 |
+
|
| 66 |
+
# Skill matching
|
| 67 |
+
scores["skills"] = cosine_similarity(profile_emb.get("skills"), job_emb.get("skills"))
|
| 68 |
+
scores["technical_skills"] = cosine_similarity(profile_emb.get("technical_skills"), job_emb.get("technical_skills"))
|
| 69 |
+
|
| 70 |
+
# Experience matching
|
| 71 |
+
scores["experience"] = cosine_similarity(profile_emb.get("experience"), job_emb.get("experience"))
|
| 72 |
+
|
| 73 |
+
# Certifications matching
|
| 74 |
+
scores["certifications"] = cosine_similarity(profile_emb.get("certifications"), job_emb.get("certifications"))
|
| 75 |
+
|
| 76 |
+
# 4. Calculate Weighted Total
|
| 77 |
+
total_score = 0
|
| 78 |
+
available_weight = 0
|
| 79 |
+
|
| 80 |
+
for key, weight in WEIGHTS.items():
|
| 81 |
+
if scores.get(key) is not None:
|
| 82 |
+
total_score += scores[key] * weight
|
| 83 |
+
available_weight += weight
|
| 84 |
+
|
| 85 |
+
# Normalize if some fields were missing (though WEIGHTS sums to 1.0)
|
| 86 |
+
if available_weight > 0:
|
| 87 |
+
final_score = (total_score / available_weight) * 100
|
| 88 |
+
else:
|
| 89 |
+
final_score = 0
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
"total_score": round(final_score, 1),
|
| 93 |
+
"breakdown": {k: round(v * 100, 1) for k, v in scores.items()},
|
| 94 |
+
"weights": WEIGHTS
|
| 95 |
+
}
|
backend/supabase_ingest.py
CHANGED
|
@@ -19,6 +19,7 @@ from supabase import create_client
|
|
| 19 |
|
| 20 |
# ✅ CORRECT IMPORT based on your file structure
|
| 21 |
from src.extraction.person_details_extraction_gemini import process_single_resume
|
|
|
|
| 22 |
|
| 23 |
# ---------------------------------------------------------------------
|
| 24 |
# ENV SETUP
|
|
@@ -235,7 +236,13 @@ def process_resume(client, user_id: str, file_path: str, temp_dir: str = "data/r
|
|
| 235 |
payload = build_resume_payload(user_id, extracted_data, file_path, file_hash)
|
| 236 |
upsert_profile(client, payload)
|
| 237 |
|
| 238 |
-
# 5.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
if os.path.exists(local_path):
|
| 240 |
os.remove(local_path)
|
| 241 |
|
|
@@ -299,6 +306,12 @@ def main():
|
|
| 299 |
# 6. Upsert to DB
|
| 300 |
upsert_profile(client, payload)
|
| 301 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
except Exception as e:
|
| 303 |
print(f" ❌ Pipeline failed for this file: {e}")
|
| 304 |
|
|
|
|
| 19 |
|
| 20 |
# ✅ CORRECT IMPORT based on your file structure
|
| 21 |
from src.extraction.person_details_extraction_gemini import process_single_resume
|
| 22 |
+
from src.embeddings.local_embedder import safe_generate_and_store_embeddings
|
| 23 |
|
| 24 |
# ---------------------------------------------------------------------
|
| 25 |
# ENV SETUP
|
|
|
|
| 236 |
payload = build_resume_payload(user_id, extracted_data, file_path, file_hash)
|
| 237 |
upsert_profile(client, payload)
|
| 238 |
|
| 239 |
+
# 5. Generate Embeddings
|
| 240 |
+
try:
|
| 241 |
+
safe_generate_and_store_embeddings(client, user_id)
|
| 242 |
+
except Exception as e:
|
| 243 |
+
print(f"⚠️ Embedding generation failed (non-critical): {e}")
|
| 244 |
+
|
| 245 |
+
# 6. Cleanup
|
| 246 |
if os.path.exists(local_path):
|
| 247 |
os.remove(local_path)
|
| 248 |
|
|
|
|
| 306 |
# 6. Upsert to DB
|
| 307 |
upsert_profile(client, payload)
|
| 308 |
|
| 309 |
+
# 7. Generate Embeddings
|
| 310 |
+
try:
|
| 311 |
+
safe_generate_and_store_embeddings(client, user_id)
|
| 312 |
+
except Exception as e:
|
| 313 |
+
print(f" ⚠️ Embedding generation failed (non-critical): {e}")
|
| 314 |
+
|
| 315 |
except Exception as e:
|
| 316 |
print(f" ❌ Pipeline failed for this file: {e}")
|
| 317 |
|
backend/update_applications_table.sql
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
-- Update applications table to include semantic match score results
|
| 3 |
+
ALTER TABLE applications
|
| 4 |
+
ADD COLUMN IF NOT EXISTS semantic_score FLOAT,
|
| 5 |
+
ADD COLUMN IF NOT EXISTS score_breakdown JSONB;
|
| 6 |
+
|
| 7 |
+
-- Add comments for documentation
|
| 8 |
+
COMMENT ON COLUMN applications.semantic_score IS 'Overall semantic match score calculated via embeddings';
|
| 9 |
+
COMMENT ON COLUMN applications.score_breakdown IS 'Breakdown of entity-wise similarity scores';
|
src/components/CandidateDrawer.jsx
CHANGED
|
@@ -132,17 +132,45 @@ 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' }}>
|
| 136 |
-
<span style={{ fontWeight: 'bold', color: 'white' }}>{analysis?.score ?? (candidate.score ||
|
| 137 |
</div>
|
| 138 |
-
<div style={{ width: '100%', height: '6px', backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: '3px' }}>
|
| 139 |
<motion.div
|
| 140 |
initial={{ width: 0 }}
|
| 141 |
-
animate={{ width: `${analysis?.score ?? (candidate.score ||
|
| 142 |
transition={{ delay: 0.2, duration: 1 }}
|
| 143 |
style={{ height: '100%', backgroundColor: '#EF4444', borderRadius: '3px', boxShadow: '0 0 10px rgba(239,68,68,0.5)' }}
|
| 144 |
/>
|
| 145 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
</div>
|
| 147 |
|
| 148 |
{loadingAnalysis && (
|
|
|
|
| 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' }}>AI Score</span>
|
| 136 |
+
<span style={{ fontWeight: 'bold', color: 'white' }}>{analysis?.score ?? (candidate.score || 0)}%</span>
|
| 137 |
</div>
|
| 138 |
+
<div style={{ width: '100%', height: '6px', backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: '3px', marginBottom: '1rem' }}>
|
| 139 |
<motion.div
|
| 140 |
initial={{ width: 0 }}
|
| 141 |
+
animate={{ width: `${analysis?.score ?? (candidate.score || 0)}%` }}
|
| 142 |
transition={{ delay: 0.2, duration: 1 }}
|
| 143 |
style={{ height: '100%', backgroundColor: '#EF4444', borderRadius: '3px', boxShadow: '0 0 10px rgba(239,68,68,0.5)' }}
|
| 144 |
/>
|
| 145 |
</div>
|
| 146 |
+
|
| 147 |
+
{analysis?.semantic_score !== undefined && (
|
| 148 |
+
<>
|
| 149 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
| 150 |
+
<span style={{ fontWeight: 'bold', color: '#38bdf8' }}>Semantic Match Score</span>
|
| 151 |
+
<span style={{ fontWeight: 'bold', color: 'white' }}>{analysis.semantic_score}%</span>
|
| 152 |
+
</div>
|
| 153 |
+
<div style={{ width: '100%', height: '6px', backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: '3px', marginBottom: '1rem' }}>
|
| 154 |
+
<motion.div
|
| 155 |
+
initial={{ width: 0 }}
|
| 156 |
+
animate={{ width: `${analysis.semantic_score}%` }}
|
| 157 |
+
transition={{ delay: 0.3, duration: 1 }}
|
| 158 |
+
style={{ height: '100%', backgroundColor: '#38bdf8', borderRadius: '3px', boxShadow: '0 0 10px rgba(56,189,248,0.5)' }}
|
| 159 |
+
/>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
{analysis.score_breakdown && (
|
| 163 |
+
<div style={{ marginTop: '0.5rem', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
| 164 |
+
{Object.entries(analysis.score_breakdown).map(([entity, score]) => (
|
| 165 |
+
<div key={entity} style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'flex', justifyContent: 'space-between' }}>
|
| 166 |
+
<span style={{ textTransform: 'capitalize' }}>{entity.replace('_', ' ')}</span>
|
| 167 |
+
<span style={{ color: 'white' }}>{score}%</span>
|
| 168 |
+
</div>
|
| 169 |
+
))}
|
| 170 |
+
</div>
|
| 171 |
+
)}
|
| 172 |
+
</>
|
| 173 |
+
)}
|
| 174 |
</div>
|
| 175 |
|
| 176 |
{loadingAnalysis && (
|
src/components/Icons.jsx
CHANGED
|
@@ -2,34 +2,34 @@ import React from 'react';
|
|
| 2 |
import { motion } from 'framer-motion';
|
| 3 |
|
| 4 |
// --- EXISTING ICONS (Do not change these) ---
|
| 5 |
-
export const LogoutIcon = () => (
|
| 6 |
-
export const BriefcaseIcon = () => (
|
| 7 |
-
export const UserCircleIcon = () => (
|
| 8 |
-
export const ChatIcon = () => (
|
| 9 |
-
export const CalendarIcon = () => (
|
| 10 |
export const UploadIcon = () => <svg style={{ width: '32px', height: '32px', color: 'rgba(255,255,255,0.5)', marginBottom: '0.5rem' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>;
|
| 11 |
-
export const SpinnerIcon = () => <motion.svg animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: "linear" }} style={{ width: '16px', height: '16px', marginRight: '8px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></motion.svg>;
|
| 12 |
-
export const SearchIcon = () => (
|
| 13 |
-
export const AtsCheckerIcon = () => (
|
| 14 |
-
export const FilterIcon = () => (
|
| 15 |
-
export const ScoringIcon = () => (
|
| 16 |
-
export const ClearIcon = () => (
|
| 17 |
-
export const ViewIcon = () => (
|
| 18 |
-
export const ChevronLeftIcon = () => (
|
| 19 |
-
export const ChevronRightIcon = () => (
|
| 20 |
-
export const CheckSquareIcon = () => (
|
| 21 |
-
export const MailIcon = () => (
|
| 22 |
-
export const LoaderIcon = () => (
|
| 23 |
|
| 24 |
// Animated Chevron for Dropdowns
|
| 25 |
-
export const ChevronDownIcon = ({ isOpen }) => (
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
);
|
| 34 |
|
| 35 |
|
|
@@ -76,7 +76,7 @@ export const RefreshIcon = ({ className }) => (
|
|
| 76 |
|
| 77 |
export const EmptyStateIcon = ({ className }) => (
|
| 78 |
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 79 |
-
|
| 80 |
</svg>
|
| 81 |
);
|
| 82 |
|
|
|
|
| 2 |
import { motion } from 'framer-motion';
|
| 3 |
|
| 4 |
// --- EXISTING ICONS (Do not change these) ---
|
| 5 |
+
export const LogoutIcon = () => (<svg style={{ width: '16px', height: '16px', marginRight: '8px' }} viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V5h10a1 1 0 100-2H3zm12.293 4.293a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 01-1.414-1.414L16.586 13H9a1 1 0 110-2h7.586l-1.293-1.293a1 1 0 010-1.414z" clipRule="evenodd" /></svg>);
|
| 6 |
+
export const BriefcaseIcon = () => (<svg style={{ width: '16px', height: '16px', marginRight: '8px' }} viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 2a2 2 0 00-2 2v1H6a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2V4a2 2 0 00-2-2zm-2 2V4h4v1H8z" clipRule="evenodd" /></svg>);
|
| 7 |
+
export const UserCircleIcon = () => (<svg style={{ width: '16px', height: '16px', marginRight: '8px' }} viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-6-3a2 2 0 11-4 0 2 2 0 014 0zm-2 4a5 5 0 00-4.546 2.916A5.986 5.986 0 0010 16a5.986 5.986 0 004.546-2.084A5 5 0 0012 11z" clipRule="evenodd" /></svg>);
|
| 8 |
+
export const ChatIcon = () => (<svg style={{ width: '16px', height: '16px', marginRight: '8px' }} viewBox="0 0 20 20" fill="currentColor"><path d="M2 5a2 2 0 012-2h12a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm1.5 0a.5.5 0 00-.5.5v6a.5.5 0 00.5.5h11a.5.5 0 00.5-.5V5.5a.5.5 0 00-.5-.5h-11z" /><path d="M8 8a1 1 0 100-2 1 1 0 000 2zm4 0a1 1 0 100-2 1 1 0 000 2zm-2 1a1 1 0 11-2 0 1 1 0 012 0z" /></svg>);
|
| 9 |
+
export const CalendarIcon = () => (<svg style={{ width: '16px', height: '16px', marginRight: '8px' }} viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd" /></svg>);
|
| 10 |
export const UploadIcon = () => <svg style={{ width: '32px', height: '32px', color: 'rgba(255,255,255,0.5)', marginBottom: '0.5rem' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>;
|
| 11 |
+
export const SpinnerIcon = () => <motion.svg animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: "linear" }} style={{ width: '16px', height: '16px', marginRight: '8px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></motion.svg>;
|
| 12 |
+
export const SearchIcon = () => (<svg style={{ width: '20px', height: '20px', color: 'rgba(255, 255, 255, 0.5)' }} viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clipRule="evenodd" /></svg>);
|
| 13 |
+
export const AtsCheckerIcon = () => (<svg style={{ width: '16px', height: '16px', marginRight: '8px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-check-square"><polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg>);
|
| 14 |
+
export const FilterIcon = () => (<svg style={{ width: '16px', height: '16px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>);
|
| 15 |
+
export const ScoringIcon = () => (<svg style={{ width: '16px', height: '16px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="20" x2="18" y2="10"></line><line x1="12" y1="20" x2="12" y2="4"></line><line x1="6" y1="20" x2="6" y2="14"></line></svg>);
|
| 16 |
+
export const ClearIcon = () => (<svg style={{ width: '16px', height: '16px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>);
|
| 17 |
+
export const ViewIcon = () => (<svg style={{ width: '16px', height: '16px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>);
|
| 18 |
+
export const ChevronLeftIcon = () => (<svg style={{ width: '16px', height: '16px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>);
|
| 19 |
+
export const ChevronRightIcon = () => (<svg style={{ width: '16px', height: '16px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>);
|
| 20 |
+
export const CheckSquareIcon = () => (<svg style={{ width: '18px', height: '18px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg>);
|
| 21 |
+
export const MailIcon = () => (<svg style={{ width: '18px', height: '18px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>);
|
| 22 |
+
export const LoaderIcon = () => (<svg style={{ width: '24px', height: '24px', animation: 'spin 1s linear infinite' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"></path></svg>);
|
| 23 |
|
| 24 |
// Animated Chevron for Dropdowns
|
| 25 |
+
export const ChevronDownIcon = ({ isOpen }) => (
|
| 26 |
+
<motion.svg
|
| 27 |
+
animate={{ rotate: isOpen ? 180 : 0 }}
|
| 28 |
+
style={{ width: '16px', height: '16px' }}
|
| 29 |
+
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
| 30 |
+
>
|
| 31 |
+
<polyline points="6 9 12 15 18 9"></polyline>
|
| 32 |
+
</motion.svg>
|
| 33 |
);
|
| 34 |
|
| 35 |
|
|
|
|
| 76 |
|
| 77 |
export const EmptyStateIcon = ({ className }) => (
|
| 78 |
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 79 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
| 80 |
</svg>
|
| 81 |
);
|
| 82 |
|