Upload 42 files
Browse files- proj/.vscode/launch.json +33 -0
- proj/.vscode/tasks.json +26 -0
- proj/README.md +76 -0
- proj/backend/.env.example +3 -0
- proj/backend/app/__pycache__/main.cpython-312.pyc +0 -0
- proj/backend/app/main.py +85 -0
- proj/backend/app/models/__pycache__/schemas.cpython-312.pyc +0 -0
- proj/backend/app/models/schemas.py +64 -0
- proj/backend/app/prompts/__pycache__/templates.cpython-312.pyc +0 -0
- proj/backend/app/prompts/templates.py +116 -0
- proj/backend/app/services/__pycache__/evaluation_service.cpython-312.pyc +0 -0
- proj/backend/app/services/__pycache__/matching_service.cpython-312.pyc +0 -0
- proj/backend/app/services/evaluation_service.py +124 -0
- proj/backend/app/services/matching_service.py +58 -0
- proj/backend/app/utils/__pycache__/groq_client.cpython-312.pyc +0 -0
- proj/backend/app/utils/__pycache__/key_manager.cpython-312.pyc +0 -0
- proj/backend/app/utils/groq_client.py +32 -0
- proj/backend/app/utils/key_manager.py +27 -0
- proj/backend/requirements.txt +12 -0
- proj/frontend/.env.local.example +1 -0
- proj/frontend/.gitignore +41 -0
- proj/frontend/AGENTS.md +5 -0
- proj/frontend/CLAUDE.md +1 -0
- proj/frontend/eslint.config.mjs +16 -0
- proj/frontend/jsconfig.json +7 -0
- proj/frontend/next.config.mjs +6 -0
- proj/frontend/package-lock.json +0 -0
- proj/frontend/package.json +27 -0
- proj/frontend/postcss.config.mjs +7 -0
- proj/frontend/public/file.svg +1 -0
- proj/frontend/public/globe.svg +1 -0
- proj/frontend/public/next.svg +1 -0
- proj/frontend/public/vercel.svg +1 -0
- proj/frontend/public/window.svg +1 -0
- proj/frontend/src/app/favicon.ico +0 -0
- proj/frontend/src/app/globals.css +62 -0
- proj/frontend/src/app/layout.jsx +36 -0
- proj/frontend/src/app/page.jsx +171 -0
- proj/frontend/src/components/CandidateDetail.jsx +154 -0
- proj/frontend/src/components/CandidateTable.jsx +81 -0
- proj/frontend/src/lib/api.js +26 -0
- proj/frontend/src/lib/api.ts +26 -0
proj/.vscode/launch.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"version": "0.2.0",
|
| 3 |
+
"configurations": [
|
| 4 |
+
{
|
| 5 |
+
"name": "Debug Backend (FastAPI)",
|
| 6 |
+
"type": "python",
|
| 7 |
+
"request": "launch",
|
| 8 |
+
"module": "uvicorn",
|
| 9 |
+
"cwd": "${workspaceFolder}/backend",
|
| 10 |
+
"args": [
|
| 11 |
+
"app.main:app",
|
| 12 |
+
"--reload",
|
| 13 |
+
"--port",
|
| 14 |
+
"8000"
|
| 15 |
+
],
|
| 16 |
+
"jinja": true,
|
| 17 |
+
"justMyCode": true
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"name": "Debug Frontend (Next.js)",
|
| 21 |
+
"type": "chrome",
|
| 22 |
+
"request": "launch",
|
| 23 |
+
"url": "http://localhost:3000",
|
| 24 |
+
"webRoot": "${workspaceFolder}/frontend"
|
| 25 |
+
}
|
| 26 |
+
],
|
| 27 |
+
"compounds": [
|
| 28 |
+
{
|
| 29 |
+
"name": "Debug All",
|
| 30 |
+
"configurations": ["Debug Backend (FastAPI)", "Debug Frontend (Next.js)"]
|
| 31 |
+
}
|
| 32 |
+
]
|
| 33 |
+
}
|
proj/.vscode/tasks.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"version": "2.0.0",
|
| 3 |
+
"tasks": [
|
| 4 |
+
{
|
| 5 |
+
"label": "Backend",
|
| 6 |
+
"type": "shell",
|
| 7 |
+
"command": "cd backend && .venv/Scripts/python -m uvicorn app.main:app --reload",
|
| 8 |
+
"presentation": {
|
| 9 |
+
"panel": "dedicated"
|
| 10 |
+
}
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"label": "Frontend",
|
| 14 |
+
"type": "shell",
|
| 15 |
+
"command": "cd frontend && npm run dev",
|
| 16 |
+
"presentation": {
|
| 17 |
+
"panel": "dedicated"
|
| 18 |
+
}
|
| 19 |
+
},
|
| 20 |
+
|
| 21 |
+
{
|
| 22 |
+
"label": "Start All",
|
| 23 |
+
"dependsOn": ["Backend", "Frontend"]
|
| 24 |
+
}
|
| 25 |
+
]
|
| 26 |
+
}
|
proj/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Full-Stack AI Recruitment Engine
|
| 2 |
+
|
| 3 |
+
A production-ready candidate ranking platform built with Next.js, FastAPI, and Groq LLMs.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
- **Multi-Agent Evaluation**: Sequential and parallel agent pipeline (Signal Extraction, Founder Eval, Tech Eval, HR Agent, etc.).
|
| 7 |
+
- **Groq Key Rotation**: Automatic round-robin cycling of multiple API keys to prevent rate limits.
|
| 8 |
+
- **CSV Data Ingestion**: Parse resumes and candidate data directly from CSV files.
|
| 9 |
+
- **Glassmorphic UI**: Modern, high-performance dashboard with animations and progress tracking.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## Backend Setup (FastAPI)
|
| 14 |
+
|
| 15 |
+
1. **Navigate to backend**:
|
| 16 |
+
```bash
|
| 17 |
+
cd backend
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
2. **Create and Activate Virtual Environment**:
|
| 21 |
+
```bash
|
| 22 |
+
python -m venv venv
|
| 23 |
+
# Windows:
|
| 24 |
+
venv\Scripts\activate
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
3. **Install Dependencies**:
|
| 28 |
+
```bash
|
| 29 |
+
pip install -r requirements.txt
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
4. **Environment Variables**:
|
| 33 |
+
Create a `.env` file in the `backend/` folder based on `.env.example`:
|
| 34 |
+
```env
|
| 35 |
+
GROQ_API_KEYS=key1,key2,key3
|
| 36 |
+
GROQ_MODEL=llama3-70b-8192
|
| 37 |
+
PORT=8000
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
5. **Run Backend**:
|
| 41 |
+
```bash
|
| 42 |
+
uvicorn app.main:app --reload
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
## Frontend Setup (Next.js)
|
| 48 |
+
|
| 49 |
+
1. **Navigate to frontend**:
|
| 50 |
+
```bash
|
| 51 |
+
cd frontend
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
2. **Install Dependencies**:
|
| 55 |
+
```bash
|
| 56 |
+
npm install
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
3. **Environment Variables**:
|
| 60 |
+
Create a `.env.local` file in the `frontend/` folder:
|
| 61 |
+
```env
|
| 62 |
+
NEXT_PUBLIC_API_URL=http://localhost:8000
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
4. **Run Frontend**:
|
| 66 |
+
```bash
|
| 67 |
+
npm run dev
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## Usage Guide
|
| 73 |
+
1. **Paste Job Description**: Enter the target role details in the textarea.
|
| 74 |
+
2. **Upload CSV**: Upload a CSV containing candidate data (columns: `name`, `email`, `skills`, `experience`, `projects`, `education`, `resume_text`).
|
| 75 |
+
3. **Evaluate**: Click "Run Evaluation" and watch the AI agents process each candidate.
|
| 76 |
+
4. **Deep Dive**: Click on any candidate row to see the full multi-agent breakdown and final synthesis.
|
proj/backend/.env.example
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
GROQ_API_KEYS=gsk_your_key_1,gsk_your_key_2,gsk_your_key_3
|
| 2 |
+
GROQ_MODEL=llama3-70b-8192
|
| 3 |
+
PORT=8000
|
proj/backend/app/__pycache__/main.cpython-312.pyc
ADDED
|
Binary file (4.54 kB). View file
|
|
|
proj/backend/app/main.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
load_dotenv()
|
| 4 |
+
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import io
|
| 7 |
+
import uuid
|
| 8 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from app.models.schemas import EvaluationRequest, EvaluationResponse, Candidate
|
| 11 |
+
from app.services.evaluation_service import evaluate_candidate
|
| 12 |
+
import asyncio
|
| 13 |
+
|
| 14 |
+
app = FastAPI(title="AI Recruitment Engine")
|
| 15 |
+
|
| 16 |
+
app.add_middleware(
|
| 17 |
+
CORSMiddleware,
|
| 18 |
+
allow_origins=["*"],
|
| 19 |
+
allow_methods=["*"],
|
| 20 |
+
allow_headers=["*"],
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# In-memory storage for demonstration (Production should use DB)
|
| 24 |
+
evaluations_cache = {}
|
| 25 |
+
|
| 26 |
+
@app.post("/upload-csv")
|
| 27 |
+
async def upload_csv(file: UploadFile = File(...)):
|
| 28 |
+
if not file.filename.endswith('.csv'):
|
| 29 |
+
raise HTTPException(status_code=400, detail="Invalid file format. Please upload a CSV.")
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
content = await file.read()
|
| 33 |
+
df = pd.read_csv(io.BytesIO(content))
|
| 34 |
+
df = df.fillna("")
|
| 35 |
+
|
| 36 |
+
candidates = []
|
| 37 |
+
for _, row in df.iterrows():
|
| 38 |
+
candidates.append(Candidate(
|
| 39 |
+
id=str(uuid.uuid4()),
|
| 40 |
+
name=str(row.get("name", "Unknown")),
|
| 41 |
+
email=str(row.get("email", "")),
|
| 42 |
+
skills=str(row.get("skills", "")),
|
| 43 |
+
experience=str(row.get("experience", "")),
|
| 44 |
+
projects=str(row.get("projects", "")),
|
| 45 |
+
education=str(row.get("education", "")),
|
| 46 |
+
resume_text=str(row.get("resume_text", ""))
|
| 47 |
+
))
|
| 48 |
+
|
| 49 |
+
return {"candidates": candidates}
|
| 50 |
+
except Exception as e:
|
| 51 |
+
raise HTTPException(status_code=500, detail=f"Error parsing CSV: {str(e)}")
|
| 52 |
+
|
| 53 |
+
@app.post("/evaluate", response_model=EvaluationResponse)
|
| 54 |
+
async def evaluate(request: EvaluationRequest):
|
| 55 |
+
if not request.jd:
|
| 56 |
+
raise HTTPException(status_code=400, detail="Job Description is required.")
|
| 57 |
+
|
| 58 |
+
if not request.candidates:
|
| 59 |
+
raise HTTPException(status_code=400, detail="No candidates provided.")
|
| 60 |
+
|
| 61 |
+
from app.services.evaluation_service import perform_hybrid_evaluation
|
| 62 |
+
response = await perform_hybrid_evaluation(request.jd, request.candidates)
|
| 63 |
+
|
| 64 |
+
# Store in cache for detail retrieval
|
| 65 |
+
for rank in response.shortlist:
|
| 66 |
+
evaluations_cache[rank.candidate_id] = rank
|
| 67 |
+
|
| 68 |
+
# Also store the deep review details
|
| 69 |
+
evaluations_cache.update(response.details)
|
| 70 |
+
|
| 71 |
+
return response
|
| 72 |
+
|
| 73 |
+
@app.get("/results")
|
| 74 |
+
async def get_results():
|
| 75 |
+
return list(evaluations_cache.values())
|
| 76 |
+
|
| 77 |
+
@app.get("/candidate/{id}")
|
| 78 |
+
async def get_candidate_report(id: str):
|
| 79 |
+
if id not in evaluations_cache:
|
| 80 |
+
raise HTTPException(status_code=404, detail="Candidate report not found.")
|
| 81 |
+
return evaluations_cache[id]
|
| 82 |
+
|
| 83 |
+
if __name__ == "__main__":
|
| 84 |
+
import uvicorn
|
| 85 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
proj/backend/app/models/__pycache__/schemas.cpython-312.pyc
ADDED
|
Binary file (3.26 kB). View file
|
|
|
proj/backend/app/models/schemas.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List, Optional, Dict, Any
|
| 3 |
+
|
| 4 |
+
class Candidate(BaseModel):
|
| 5 |
+
id: str
|
| 6 |
+
name: str
|
| 7 |
+
email: Optional[str] = None
|
| 8 |
+
skills: Optional[str] = None
|
| 9 |
+
experience: Optional[str] = None
|
| 10 |
+
projects: Optional[str] = None
|
| 11 |
+
education: Optional[str] = None
|
| 12 |
+
resume_text: Optional[str] = None
|
| 13 |
+
# Add other fields from the CSV if needed
|
| 14 |
+
data: Optional[Dict[str, Any]] = None
|
| 15 |
+
|
| 16 |
+
class NormalizedCandidate(BaseModel):
|
| 17 |
+
candidate_id: str
|
| 18 |
+
name: str
|
| 19 |
+
normalized_title: str
|
| 20 |
+
experience_years: float
|
| 21 |
+
primary_skills: List[str]
|
| 22 |
+
secondary_skills: List[str]
|
| 23 |
+
backend_score: float
|
| 24 |
+
frontend_score: float
|
| 25 |
+
cloud_score: float
|
| 26 |
+
database_score: float
|
| 27 |
+
notice_period_days: int
|
| 28 |
+
location: str
|
| 29 |
+
employment_status: str
|
| 30 |
+
salary_expectation: str
|
| 31 |
+
flags: List[str]
|
| 32 |
+
|
| 33 |
+
class RerankResult(BaseModel):
|
| 34 |
+
candidate_id: str
|
| 35 |
+
scores: Dict[str, float]
|
| 36 |
+
final_score: float
|
| 37 |
+
decision: str
|
| 38 |
+
|
| 39 |
+
class DeepReview(BaseModel):
|
| 40 |
+
candidate_id: str
|
| 41 |
+
verdict: str
|
| 42 |
+
why: str
|
| 43 |
+
strengths: List[str]
|
| 44 |
+
risks: List[str]
|
| 45 |
+
hidden_signal: str
|
| 46 |
+
confidence: float
|
| 47 |
+
|
| 48 |
+
class FinalRank(BaseModel):
|
| 49 |
+
rank: int
|
| 50 |
+
candidate_id: str
|
| 51 |
+
name: str
|
| 52 |
+
decision: str
|
| 53 |
+
reason: str
|
| 54 |
+
|
| 55 |
+
class FinalShortlist(BaseModel):
|
| 56 |
+
final_ranking: List[FinalRank]
|
| 57 |
+
|
| 58 |
+
class EvaluationRequest(BaseModel):
|
| 59 |
+
jd: str
|
| 60 |
+
candidates: List[Candidate]
|
| 61 |
+
|
| 62 |
+
class EvaluationResponse(BaseModel):
|
| 63 |
+
shortlist: List[FinalRank]
|
| 64 |
+
details: Dict[str, Any] # Map candidate_id to their full review for UI
|
proj/backend/app/prompts/__pycache__/templates.cpython-312.pyc
ADDED
|
Binary file (2.17 kB). View file
|
|
|
proj/backend/app/prompts/templates.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# STAGE 1 — STRUCTURED PIPELINE EXTRACTION PROMPT
|
| 2 |
+
STAGE1_NORMALIZATION_PROMPT = """
|
| 3 |
+
ROLE: You are a candidate normalization system. You receive already-structured candidate CSV data.
|
| 4 |
+
Your task is to clean, normalize, and prepare candidate features for ranking.
|
| 5 |
+
|
| 6 |
+
JOB DESCRIPTION:
|
| 7 |
+
{jd}
|
| 8 |
+
|
| 9 |
+
CANDIDATE DATA:
|
| 10 |
+
{candidate_raw}
|
| 11 |
+
|
| 12 |
+
TASK: Return normalized structured JSON only.
|
| 13 |
+
RULES: No opinions, No ranking, No explanations, No hallucination, Use only available fields, Standardize values.
|
| 14 |
+
|
| 15 |
+
OUTPUT JSON FORMAT:
|
| 16 |
+
{{
|
| 17 |
+
"candidate_id": "",
|
| 18 |
+
"name": "",
|
| 19 |
+
"normalized_title": "",
|
| 20 |
+
"experience_years": 0,
|
| 21 |
+
"primary_skills": [],
|
| 22 |
+
"secondary_skills": [],
|
| 23 |
+
"backend_score": 0,
|
| 24 |
+
"frontend_score": 0,
|
| 25 |
+
"cloud_score": 0,
|
| 26 |
+
"database_score": 0,
|
| 27 |
+
"notice_period_days": 0,
|
| 28 |
+
"location": "",
|
| 29 |
+
"employment_status": "",
|
| 30 |
+
"salary_expectation": "",
|
| 31 |
+
"flags": []
|
| 32 |
+
}}
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
# STAGE 3 — DETERMINISTIC RERANKING PROMPT
|
| 36 |
+
STAGE3_RERANK_PROMPT = """
|
| 37 |
+
ROLE: You are a candidate scoring engine. Use structured candidate features and JD requirements to assign weighted scores.
|
| 38 |
+
|
| 39 |
+
JOB DESCRIPTION:
|
| 40 |
+
{jd}
|
| 41 |
+
|
| 42 |
+
CANDIDATE_FEATURES:
|
| 43 |
+
{normalized_candidate}
|
| 44 |
+
|
| 45 |
+
TASK: Score candidate using deterministic logic.
|
| 46 |
+
WEIGHTS:
|
| 47 |
+
- Skill Match = 35%
|
| 48 |
+
- Experience Match = 25%
|
| 49 |
+
- Role Relevance = 20%
|
| 50 |
+
- Cloud/Infra Fit = 10%
|
| 51 |
+
- Notice Period = 10%
|
| 52 |
+
|
| 53 |
+
OUTPUT JSON:
|
| 54 |
+
{{
|
| 55 |
+
"candidate_id": "",
|
| 56 |
+
"scores": {{
|
| 57 |
+
"skill_match": 0,
|
| 58 |
+
"experience_match": 0,
|
| 59 |
+
"role_relevance": 0,
|
| 60 |
+
"infra_fit": 0,
|
| 61 |
+
"notice_fit": 0
|
| 62 |
+
}},
|
| 63 |
+
"final_score": 0,
|
| 64 |
+
"decision": "pass / reject"
|
| 65 |
+
}}
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
# STAGE 4 — LLM DEEP REVIEW PROMPT
|
| 69 |
+
STAGE4_DEEP_REVIEW_PROMPT = """
|
| 70 |
+
ROLE: You are a senior hiring evaluator. You receive only top shortlisted candidates.
|
| 71 |
+
Use nuanced reasoning to identify strongest hires.
|
| 72 |
+
|
| 73 |
+
JOB DESCRIPTION:
|
| 74 |
+
{jd}
|
| 75 |
+
|
| 76 |
+
CANDIDATE:
|
| 77 |
+
{candidate_data}
|
| 78 |
+
|
| 79 |
+
RERANK_SCORE:
|
| 80 |
+
{score}
|
| 81 |
+
|
| 82 |
+
TASK: Evaluate hidden strengths, practical fit, risks, and hiring recommendation.
|
| 83 |
+
RULES: Use candidate evidence only, No hallucinations, Be concise, Be decisive.
|
| 84 |
+
|
| 85 |
+
OUTPUT JSON:
|
| 86 |
+
{{
|
| 87 |
+
"verdict": "strong hire / hire / consider / reject",
|
| 88 |
+
"why": "",
|
| 89 |
+
"strengths": [],
|
| 90 |
+
"risks": [],
|
| 91 |
+
"hidden_signal": "",
|
| 92 |
+
"confidence": 0
|
| 93 |
+
}}
|
| 94 |
+
"""
|
| 95 |
+
|
| 96 |
+
# STAGE 5 — FINAL SELECTION PROMPT
|
| 97 |
+
STAGE5_FINAL_SELECTION_PROMPT = """
|
| 98 |
+
ROLE: You are the final hiring decision engine. Combine rerank scores and LLM evaluations into final shortlist.
|
| 99 |
+
|
| 100 |
+
TOP_CANDIDATES:
|
| 101 |
+
{all_top_5_results}
|
| 102 |
+
|
| 103 |
+
TASK: Return final ordered ranking.
|
| 104 |
+
OUTPUT JSON:
|
| 105 |
+
{{
|
| 106 |
+
"final_ranking": [
|
| 107 |
+
{{
|
| 108 |
+
"rank": 1,
|
| 109 |
+
"candidate_id": "",
|
| 110 |
+
"name": "",
|
| 111 |
+
"decision": "",
|
| 112 |
+
"reason": ""
|
| 113 |
+
}}
|
| 114 |
+
]
|
| 115 |
+
}}
|
| 116 |
+
"""
|
proj/backend/app/services/__pycache__/evaluation_service.cpython-312.pyc
ADDED
|
Binary file (8.46 kB). View file
|
|
|
proj/backend/app/services/__pycache__/matching_service.cpython-312.pyc
ADDED
|
Binary file (3.15 kB). View file
|
|
|
proj/backend/app/services/evaluation_service.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import re
|
| 4 |
+
from typing import List, Dict, Any
|
| 5 |
+
from app.utils.groq_client import get_groq_completion
|
| 6 |
+
from app.models.schemas import (
|
| 7 |
+
Candidate, NormalizedCandidate, RerankResult,
|
| 8 |
+
DeepReview, FinalShortlist, FinalRank, EvaluationResponse
|
| 9 |
+
)
|
| 10 |
+
from app.services.matching_service import match_service
|
| 11 |
+
from app.prompts.templates import (
|
| 12 |
+
STAGE1_NORMALIZATION_PROMPT,
|
| 13 |
+
STAGE3_RERANK_PROMPT,
|
| 14 |
+
STAGE4_DEEP_REVIEW_PROMPT,
|
| 15 |
+
STAGE5_FINAL_SELECTION_PROMPT
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
# Concurrency Throttling
|
| 19 |
+
sem = asyncio.Semaphore(3)
|
| 20 |
+
|
| 21 |
+
async def get_completion_with_sem(messages):
|
| 22 |
+
async with sem:
|
| 23 |
+
return await get_groq_completion(messages)
|
| 24 |
+
|
| 25 |
+
async def normalize_candidate(jd: str, candidate: Candidate) -> NormalizedCandidate:
|
| 26 |
+
candidate_raw = candidate.model_dump_json()
|
| 27 |
+
resp = await get_completion_with_sem([
|
| 28 |
+
{"role": "system", "content": "You are a professional data normalizer. Output JSON ONLY."},
|
| 29 |
+
{"role": "user", "content": STAGE1_NORMALIZATION_PROMPT.format(jd=jd, candidate_raw=candidate_raw)}
|
| 30 |
+
])
|
| 31 |
+
try:
|
| 32 |
+
match = re.search(r'\{.*\}', resp, re.DOTALL)
|
| 33 |
+
data = json.loads(match.group() if match else resp)
|
| 34 |
+
return NormalizedCandidate(**data)
|
| 35 |
+
except Exception as e:
|
| 36 |
+
print(f"Failed to normalize {candidate.name}: {e}")
|
| 37 |
+
# Return a fallback object
|
| 38 |
+
return NormalizedCandidate(
|
| 39 |
+
candidate_id=candidate.id, name=candidate.name, normalized_title="Unknown",
|
| 40 |
+
experience_years=0, primary_skills=[], secondary_skills=[],
|
| 41 |
+
backend_score=0, frontend_score=0, cloud_score=0, database_score=0,
|
| 42 |
+
notice_period_days=0, location="Unknown", employment_status="Unknown",
|
| 43 |
+
salary_expectation="Unknown", flags=["Parsing Error"]
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
async def rerank_candidate(jd: str, normalized: NormalizedCandidate) -> RerankResult:
|
| 47 |
+
resp = await get_completion_with_sem([
|
| 48 |
+
{"role": "system", "content": "You are a recruitment scoring engine. Output JSON ONLY."},
|
| 49 |
+
{"role": "user", "content": STAGE3_RERANK_PROMPT.format(jd=jd, normalized_candidate=normalized.model_dump_json())}
|
| 50 |
+
])
|
| 51 |
+
try:
|
| 52 |
+
match = re.search(r'\{.*\}', resp, re.DOTALL)
|
| 53 |
+
data = json.loads(match.group() if match else resp)
|
| 54 |
+
return RerankResult(**data)
|
| 55 |
+
except:
|
| 56 |
+
return RerankResult(candidate_id=normalized.candidate_id, scores={}, final_score=0, decision="reject")
|
| 57 |
+
|
| 58 |
+
async def review_candidate(jd: str, candidate_data: str, score: float, cand_id: str) -> DeepReview:
|
| 59 |
+
resp = await get_completion_with_sem([
|
| 60 |
+
{"role": "system", "content": "You are a senior hiring evaluator. Output JSON ONLY."},
|
| 61 |
+
{"role": "user", "content": STAGE4_DEEP_REVIEW_PROMPT.format(jd=jd, candidate_data=candidate_data, score=score)}
|
| 62 |
+
])
|
| 63 |
+
try:
|
| 64 |
+
match = re.search(r'\{.*\}', resp, re.DOTALL)
|
| 65 |
+
data = json.loads(match.group() if match else resp)
|
| 66 |
+
data["candidate_id"] = cand_id
|
| 67 |
+
return DeepReview(**data)
|
| 68 |
+
except:
|
| 69 |
+
return DeepReview(candidate_id=cand_id, verdict="reject", why="Error in evaluation", strengths=[], risks=[], hidden_signal="", confidence=0)
|
| 70 |
+
|
| 71 |
+
async def perform_hybrid_evaluation(jd: str, candidates: List[Candidate]) -> EvaluationResponse:
|
| 72 |
+
# 1. Normalization (Stage 1) - All candidates
|
| 73 |
+
normalization_tasks = [normalize_candidate(jd, c) for c in candidates]
|
| 74 |
+
normalized_candidates = await asyncio.gather(*normalization_tasks)
|
| 75 |
+
|
| 76 |
+
# Map for easy lookup
|
| 77 |
+
normalized_map = {n.candidate_id: n for n in normalized_candidates}
|
| 78 |
+
candidate_map = {c.id: c for c in candidates}
|
| 79 |
+
|
| 80 |
+
# 2. Embedding Matching (Stage 2) - Retrieves Top 20
|
| 81 |
+
# We pass the normalized summary/skills for better matching
|
| 82 |
+
top_20 = await match_service.get_top_candidates(jd, candidates)
|
| 83 |
+
|
| 84 |
+
# 3. Deterministic Reranking (Stage 3) - Top 20 -> Top 10
|
| 85 |
+
rerank_tasks = [rerank_candidate(jd, normalized_map[c.id]) for c in top_20]
|
| 86 |
+
rerank_results = await asyncio.gather(*rerank_tasks)
|
| 87 |
+
rerank_results.sort(key=lambda x: x.final_score, reverse=True)
|
| 88 |
+
top_10_results = rerank_results[:10]
|
| 89 |
+
|
| 90 |
+
# 4. LLM Deep Review (Stage 4) - Top 5 Only
|
| 91 |
+
top_5_for_review = top_10_results[:5]
|
| 92 |
+
review_tasks = [
|
| 93 |
+
review_candidate(
|
| 94 |
+
jd,
|
| 95 |
+
candidate_map[r.candidate_id].model_dump_json(),
|
| 96 |
+
r.final_score,
|
| 97 |
+
r.candidate_id
|
| 98 |
+
) for r in top_5_for_review
|
| 99 |
+
]
|
| 100 |
+
review_results = await asyncio.gather(*review_tasks)
|
| 101 |
+
review_map = {rev.candidate_id: rev for rev in review_results}
|
| 102 |
+
|
| 103 |
+
# 5. Final Selection (Stage 5)
|
| 104 |
+
reviews_json = json.dumps([r.model_dump() for r in review_results])
|
| 105 |
+
final_resp = await get_completion_with_sem([
|
| 106 |
+
{"role": "system", "content": "You are the final hiring decision officer. Output JSON ONLY."},
|
| 107 |
+
{"role": "user", "content": STAGE5_FINAL_SELECTION_PROMPT.format(all_top_5_results=reviews_json)}
|
| 108 |
+
])
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
match = re.search(r'\{.*\}', final_resp, re.DOTALL)
|
| 112 |
+
final_data = json.loads(match.group() if match else final_resp)
|
| 113 |
+
shortlist = FinalShortlist(**final_data)
|
| 114 |
+
except:
|
| 115 |
+
# Fallback ranking if synthesis fails
|
| 116 |
+
shortlist = FinalShortlist(final_ranking=[
|
| 117 |
+
FinalRank(rank=i+1, candidate_id=r.candidate_id, name=candidate_map[r.candidate_id].name, decision=r.decision, reason="Automatic ranking")
|
| 118 |
+
for i, r in enumerate(top_5_for_review)
|
| 119 |
+
])
|
| 120 |
+
|
| 121 |
+
return EvaluationResponse(
|
| 122 |
+
shortlist=shortlist.final_ranking,
|
| 123 |
+
details={rev.candidate_id: rev.model_dump() for rev in review_results}
|
| 124 |
+
)
|
proj/backend/app/services/matching_service.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pinecone import Pinecone
|
| 3 |
+
from sentence_transformers import SentenceTransformer
|
| 4 |
+
from typing import List
|
| 5 |
+
from app.models.schemas import Candidate
|
| 6 |
+
|
| 7 |
+
class MatchService:
|
| 8 |
+
def __init__(self):
|
| 9 |
+
self.pc = Pinecone(api_key=os.getenv("PINECONE_API_KEY"))
|
| 10 |
+
self.index_name = os.getenv("PINECONE_INDEX", "coderound")
|
| 11 |
+
self.index = self.pc.Index(self.index_name)
|
| 12 |
+
|
| 13 |
+
# Load local embedding model
|
| 14 |
+
model_name = os.getenv("EMBEDDING_MODEL", "BAAI/bge-m3")
|
| 15 |
+
self.model = SentenceTransformer(model_name)
|
| 16 |
+
|
| 17 |
+
self.top_k = int(os.getenv("STAGE2_TOP_K", "20"))
|
| 18 |
+
|
| 19 |
+
def get_embedding(self, text: str):
|
| 20 |
+
return self.model.encode(text).tolist()
|
| 21 |
+
|
| 22 |
+
async def get_top_candidates(self, jd: str, candidates: List[Candidate]) -> List[Candidate]:
|
| 23 |
+
# 1. Prepare vectors for batch upload
|
| 24 |
+
vectors = []
|
| 25 |
+
candidate_map = {}
|
| 26 |
+
|
| 27 |
+
for c in candidates:
|
| 28 |
+
# Combine fields for semantic weight
|
| 29 |
+
search_text = f"{c.name} {c.skills} {c.experience} {c.projects} {c.resume_text}"
|
| 30 |
+
embedding = self.get_embedding(search_text)
|
| 31 |
+
|
| 32 |
+
vectors.append({
|
| 33 |
+
"id": c.id,
|
| 34 |
+
"values": embedding,
|
| 35 |
+
"metadata": {"name": c.name, "email": c.email}
|
| 36 |
+
})
|
| 37 |
+
candidate_map[c.id] = c
|
| 38 |
+
|
| 39 |
+
# 2. Upsert to Pinecone
|
| 40 |
+
self.index.upsert(vectors=vectors)
|
| 41 |
+
|
| 42 |
+
# 3. Embed JD and Query
|
| 43 |
+
jd_embedding = self.get_embedding(jd)
|
| 44 |
+
query_results = self.index.query(
|
| 45 |
+
vector=jd_embedding,
|
| 46 |
+
top_k=self.top_k,
|
| 47 |
+
include_metadata=True
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# 4. Map back to Candidate objects
|
| 51 |
+
top_candidates = []
|
| 52 |
+
for match in query_results.matches:
|
| 53 |
+
if match.id in candidate_map:
|
| 54 |
+
top_candidates.append(candidate_map[match.id])
|
| 55 |
+
|
| 56 |
+
return top_candidates
|
| 57 |
+
|
| 58 |
+
match_service = MatchService()
|
proj/backend/app/utils/__pycache__/groq_client.cpython-312.pyc
ADDED
|
Binary file (1.77 kB). View file
|
|
|
proj/backend/app/utils/__pycache__/key_manager.cpython-312.pyc
ADDED
|
Binary file (2 kB). View file
|
|
|
proj/backend/app/utils/groq_client.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import asyncio
|
| 3 |
+
from groq import AsyncGroq
|
| 4 |
+
from app.utils.key_manager import key_manager
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
async def get_groq_completion(messages: list, model: str = None) -> str:
|
| 10 |
+
if model is None:
|
| 11 |
+
model = os.getenv("GROQ_MODEL", "llama3-70b-8192")
|
| 12 |
+
|
| 13 |
+
max_retries = len(key_manager.keys)
|
| 14 |
+
last_error = None
|
| 15 |
+
|
| 16 |
+
for _ in range(max_retries):
|
| 17 |
+
try:
|
| 18 |
+
api_key = key_manager.get_next_key()
|
| 19 |
+
client = AsyncGroq(api_key=api_key)
|
| 20 |
+
|
| 21 |
+
response = await client.chat.completions.create(
|
| 22 |
+
messages=messages,
|
| 23 |
+
model=model,
|
| 24 |
+
temperature=0.7,
|
| 25 |
+
)
|
| 26 |
+
return response.choices[0].message.content
|
| 27 |
+
except Exception as e:
|
| 28 |
+
logger.warning(f"Error using key: {e}. Retrying with next key...")
|
| 29 |
+
last_error = e
|
| 30 |
+
continue
|
| 31 |
+
|
| 32 |
+
raise Exception(f"All API keys exhausted or failed. Last error: {last_error}")
|
proj/backend/app/utils/key_manager.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import threading
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
class KeyRotationManager:
|
| 6 |
+
def __init__(self):
|
| 7 |
+
keys_str = os.getenv("GROQ_API_KEYS")
|
| 8 |
+
if not keys_str:
|
| 9 |
+
keys_str = os.getenv("GROQ_API_KEY", "")
|
| 10 |
+
|
| 11 |
+
self.keys = [k.strip() for k in keys_str.split(",") if k.strip()]
|
| 12 |
+
print(f"KeyRotationManager initialized with {len(self.keys)} keys.")
|
| 13 |
+
if not self.keys:
|
| 14 |
+
print("WARNING: No GROQ_API_KEYS or GROQ_API_KEY found in environment!")
|
| 15 |
+
self.current_index = 0
|
| 16 |
+
self.lock = threading.Lock()
|
| 17 |
+
|
| 18 |
+
def get_next_key(self) -> str:
|
| 19 |
+
with self.lock:
|
| 20 |
+
if not self.keys:
|
| 21 |
+
raise ValueError("No GROQ_API_KEYS found in environment variables.")
|
| 22 |
+
|
| 23 |
+
key = self.keys[self.current_index]
|
| 24 |
+
self.current_index = (self.current_index + 1) % len(self.keys)
|
| 25 |
+
return key
|
| 26 |
+
|
| 27 |
+
key_manager = KeyRotationManager()
|
proj/backend/requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
python-dotenv
|
| 4 |
+
pandas
|
| 5 |
+
groq
|
| 6 |
+
pydantic
|
| 7 |
+
python-multipart
|
| 8 |
+
asyncio
|
| 9 |
+
httpx
|
| 10 |
+
pinecone
|
| 11 |
+
sentence-transformers
|
| 12 |
+
numpy
|
proj/frontend/.env.local.example
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
NEXT_PUBLIC_API_URL=http://localhost:8000
|
proj/frontend/.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
proj/frontend/AGENTS.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- BEGIN:nextjs-agent-rules -->
|
| 2 |
+
# This is NOT the Next.js you know
|
| 3 |
+
|
| 4 |
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
| 5 |
+
<!-- END:nextjs-agent-rules -->
|
proj/frontend/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@AGENTS.md
|
proj/frontend/eslint.config.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
|
| 4 |
+
const eslintConfig = defineConfig([
|
| 5 |
+
...nextVitals,
|
| 6 |
+
// Override default ignores of eslint-config-next.
|
| 7 |
+
globalIgnores([
|
| 8 |
+
// Default ignores of eslint-config-next:
|
| 9 |
+
".next/**",
|
| 10 |
+
"out/**",
|
| 11 |
+
"build/**",
|
| 12 |
+
"next-env.d.ts",
|
| 13 |
+
]),
|
| 14 |
+
]);
|
| 15 |
+
|
| 16 |
+
export default eslintConfig;
|
proj/frontend/jsconfig.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"paths": {
|
| 4 |
+
"@/*": ["./src/*"]
|
| 5 |
+
}
|
| 6 |
+
}
|
| 7 |
+
}
|
proj/frontend/next.config.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
/* config options here */
|
| 4 |
+
};
|
| 5 |
+
|
| 6 |
+
export default nextConfig;
|
proj/frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
proj/frontend/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"next": "16.2.3",
|
| 13 |
+
"react": "19.2.4",
|
| 14 |
+
"react-dom": "19.2.4",
|
| 15 |
+
"axios": "^1.6.7",
|
| 16 |
+
"lucide-react": "latest",
|
| 17 |
+
"framer-motion": "^11.0.8",
|
| 18 |
+
"clsx": "^2.1.0",
|
| 19 |
+
"tailwind-merge": "^2.2.1"
|
| 20 |
+
},
|
| 21 |
+
"devDependencies": {
|
| 22 |
+
"@tailwindcss/postcss": "^4",
|
| 23 |
+
"eslint": "^9",
|
| 24 |
+
"eslint-config-next": "16.2.3",
|
| 25 |
+
"tailwindcss": "^4"
|
| 26 |
+
}
|
| 27 |
+
}
|
proj/frontend/postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
proj/frontend/public/file.svg
ADDED
|
|
proj/frontend/public/globe.svg
ADDED
|
|
proj/frontend/public/next.svg
ADDED
|
|
proj/frontend/public/vercel.svg
ADDED
|
|
proj/frontend/public/window.svg
ADDED
|
|
proj/frontend/src/app/favicon.ico
ADDED
|
|
proj/frontend/src/app/globals.css
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--background: #f8fafc;
|
| 5 |
+
--foreground: #0f172a;
|
| 6 |
+
--primary: #6366f1;
|
| 7 |
+
--glass: rgba(255, 255, 255, 0.7);
|
| 8 |
+
--glass-border: rgba(255, 255, 255, 0.3);
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
.dark {
|
| 12 |
+
--background: #020617;
|
| 13 |
+
--foreground: #f8fafc;
|
| 14 |
+
--primary: #818cf8;
|
| 15 |
+
--glass: rgba(15, 23, 42, 0.7);
|
| 16 |
+
--glass-border: rgba(30, 41, 59, 0.5);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
body {
|
| 20 |
+
background-color: var(--background);
|
| 21 |
+
color: var(--foreground);
|
| 22 |
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
| 23 |
+
overflow-x: hidden;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.glass-card {
|
| 27 |
+
background: var(--glass);
|
| 28 |
+
backdrop-filter: blur(12px);
|
| 29 |
+
-webkit-backdrop-filter: blur(12px);
|
| 30 |
+
border: 1px solid var(--glass-border);
|
| 31 |
+
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.gradient-text {
|
| 35 |
+
background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
|
| 36 |
+
-webkit-background-clip: text;
|
| 37 |
+
-webkit-text-fill-color: transparent;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.animate-in {
|
| 41 |
+
animation: fadeIn 0.5s ease-out forwards;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
@keyframes fadeIn {
|
| 45 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 46 |
+
to { opacity: 1; transform: translateY(0); }
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/* Custom Scrollbar */
|
| 50 |
+
::-webkit-scrollbar {
|
| 51 |
+
width: 6px;
|
| 52 |
+
}
|
| 53 |
+
::-webkit-scrollbar-track {
|
| 54 |
+
background: transparent;
|
| 55 |
+
}
|
| 56 |
+
::-webkit-scrollbar-thumb {
|
| 57 |
+
background: #cbd5e1;
|
| 58 |
+
border-radius: 10px;
|
| 59 |
+
}
|
| 60 |
+
.dark ::-webkit-scrollbar-thumb {
|
| 61 |
+
background: #334155;
|
| 62 |
+
}
|
proj/frontend/src/app/layout.jsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import './globals.css';
|
| 2 |
+
import { Inter } from 'next/font/google';
|
| 3 |
+
|
| 4 |
+
const inter = Inter({ subsets: ['latin'] });
|
| 5 |
+
|
| 6 |
+
export const metadata = {
|
| 7 |
+
title: 'AI Recruitment Engine | Elevate Your Hiring',
|
| 8 |
+
description: 'Production-ready AI candidate ranking platform powered by Groq and multi-agent synergy.',
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export default function RootLayout({ children }) {
|
| 12 |
+
return (
|
| 13 |
+
<html lang="en">
|
| 14 |
+
<body className={inter.className}>
|
| 15 |
+
<div className="fixed inset-0 -z-10 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-indigo-100 via-slate-50 to-white dark:from-indigo-950 dark:via-slate-950 dark:to-black"></div>
|
| 16 |
+
<nav className="sticky top-0 z-50 w-full glass-card border-b bg-white/50 dark:bg-black/50 backdrop-blur-md">
|
| 17 |
+
<div className="container mx-auto px-6 py-4 flex justify-between items-center">
|
| 18 |
+
<h1 className="text-2xl font-bold gradient-text">RecruitAI</h1>
|
| 19 |
+
<div className="flex gap-6 items-center">
|
| 20 |
+
<span className="text-sm font-medium text-slate-500 hover:text-indigo-600 cursor-pointer transition-colors">Dashboard</span>
|
| 21 |
+
<span className="text-sm font-medium text-slate-500 hover:text-indigo-600 cursor-pointer transition-colors">Analytics</span>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
</nav>
|
| 25 |
+
<main className="min-h-screen">
|
| 26 |
+
{children}
|
| 27 |
+
</main>
|
| 28 |
+
<footer className="py-12 border-t border-slate-200 dark:border-slate-800 backdrop-blur-sm">
|
| 29 |
+
<div className="container mx-auto px-6 text-center text-slate-500 text-sm">
|
| 30 |
+
© 2024 AI Recruitment Engine. Built for the future of hiring.
|
| 31 |
+
</div>
|
| 32 |
+
</footer>
|
| 33 |
+
</body>
|
| 34 |
+
</html>
|
| 35 |
+
);
|
| 36 |
+
}
|
proj/frontend/src/app/page.jsx
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React, { useState } from 'react';
|
| 4 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 5 |
+
import { Upload, Search, Zap, CheckCircle2, AlertCircle, Loader2, BarChart3, Users } from 'lucide-react';
|
| 6 |
+
import { uploadCSV, runEvaluation } from '@/lib/api';
|
| 7 |
+
import CandidateTable from '@/components/CandidateTable';
|
| 8 |
+
import CandidateDetail from '@/components/CandidateDetail';
|
| 9 |
+
|
| 10 |
+
export default function Dashboard() {
|
| 11 |
+
const [jd, setJd] = useState('');
|
| 12 |
+
const [candidates, setCandidates] = useState([]);
|
| 13 |
+
const [evaluations, setEvaluations] = useState([]);
|
| 14 |
+
const [isUploading, setIsUploading] = useState(false);
|
| 15 |
+
const [isEvaluating, setIsEvaluating] = useState(false);
|
| 16 |
+
const [selectedCandidate, setSelectedCandidate] = useState(null);
|
| 17 |
+
|
| 18 |
+
const handleFileUpload = async (e) => {
|
| 19 |
+
const file = e.target.files[0];
|
| 20 |
+
if (!file) return;
|
| 21 |
+
|
| 22 |
+
setIsUploading(true);
|
| 23 |
+
try {
|
| 24 |
+
const data = await uploadCSV(file);
|
| 25 |
+
setCandidates(data.candidates);
|
| 26 |
+
} catch (err) {
|
| 27 |
+
console.error(err);
|
| 28 |
+
alert('Failed to upload CSV. Check console for details.');
|
| 29 |
+
} finally {
|
| 30 |
+
setIsUploading(false);
|
| 31 |
+
}
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
const handleEvaluate = async () => {
|
| 35 |
+
if (!jd || candidates.length === 0) {
|
| 36 |
+
alert('Please provide a JD and upload candidates.');
|
| 37 |
+
return;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
setIsEvaluating(true);
|
| 41 |
+
try {
|
| 42 |
+
const data = await runEvaluation(jd, candidates);
|
| 43 |
+
setEvaluations(data.shortlist);
|
| 44 |
+
// We can also store data.details if needed for immediate access
|
| 45 |
+
window.evalDetails = data.details;
|
| 46 |
+
} catch (err) {
|
| 47 |
+
console.error(err);
|
| 48 |
+
alert('Evaluation failed. Check backend/Groq keys.');
|
| 49 |
+
} finally {
|
| 50 |
+
setIsEvaluating(false);
|
| 51 |
+
}
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
return (
|
| 55 |
+
<div className="container mx-auto px-6 py-12">
|
| 56 |
+
<header className="mb-12">
|
| 57 |
+
<motion.h2
|
| 58 |
+
initial={{ opacity: 0, x: -20 }}
|
| 59 |
+
animate={{ opacity: 1, x: 0 }}
|
| 60 |
+
className="text-4xl font-extrabold tracking-tight"
|
| 61 |
+
>
|
| 62 |
+
Candidate <span className="gradient-text">Ranking Platform</span>
|
| 63 |
+
</motion.h2>
|
| 64 |
+
<p className="text-slate-500 mt-2 text-lg">Intelligent multi-agent assessment for your next top hire.</p>
|
| 65 |
+
</header>
|
| 66 |
+
|
| 67 |
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
| 68 |
+
{/* Left Column: Input */}
|
| 69 |
+
<div className="lg:col-span-4 space-y-6">
|
| 70 |
+
<section className="glass-card p-6 rounded-2xl">
|
| 71 |
+
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
| 72 |
+
<Zap className="w-5 h-5 text-indigo-500" /> Job Description
|
| 73 |
+
</h3>
|
| 74 |
+
<textarea
|
| 75 |
+
className="w-full h-64 p-4 bg-white/50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all resize-none text-sm"
|
| 76 |
+
placeholder="Paste the job description here..."
|
| 77 |
+
value={jd}
|
| 78 |
+
onChange={(e) => setJd(e.target.value)}
|
| 79 |
+
/>
|
| 80 |
+
</section>
|
| 81 |
+
|
| 82 |
+
<section className="glass-card p-6 rounded-2xl">
|
| 83 |
+
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-indigo-500">
|
| 84 |
+
<Upload className="w-5 h-5" /> Candidate Data (CSV)
|
| 85 |
+
</h3>
|
| 86 |
+
<div className="relative group">
|
| 87 |
+
<input
|
| 88 |
+
type="file"
|
| 89 |
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
| 90 |
+
onChange={handleFileUpload}
|
| 91 |
+
accept=".csv"
|
| 92 |
+
/>
|
| 93 |
+
<div className="border-2 border-dashed border-slate-300 dark:border-slate-700 rounded-xl p-8 text-center group-hover:border-indigo-500 transition-colors">
|
| 94 |
+
{isUploading ? (
|
| 95 |
+
<Loader2 className="w-8 h-8 mx-auto animate-spin text-indigo-500" />
|
| 96 |
+
) : (
|
| 97 |
+
<>
|
| 98 |
+
<Users className="w-8 h-8 mx-auto text-slate-400 group-hover:text-indigo-500 transition-colors mb-2" />
|
| 99 |
+
<p className="text-sm font-medium">Click or drag CSV</p>
|
| 100 |
+
<p className="text-xs text-slate-500 mt-1">{candidates.length > 0 ? `${candidates.length} candidates loaded` : 'Supports names, skills, email...'}</p>
|
| 101 |
+
</>
|
| 102 |
+
)}
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
</section>
|
| 106 |
+
|
| 107 |
+
<button
|
| 108 |
+
onClick={handleEvaluate}
|
| 109 |
+
disabled={isEvaluating || !jd || candidates.length === 0}
|
| 110 |
+
className={`w-full py-4 rounded-xl flex items-center justify-center gap-2 font-bold text-white transition-all shadow-lg ${
|
| 111 |
+
isEvaluating || !jd || candidates.length === 0
|
| 112 |
+
? 'bg-slate-400 cursor-not-allowed'
|
| 113 |
+
: 'bg-indigo-600 hover:bg-indigo-700 hover:scale-[1.02] active:scale-[0.98]'
|
| 114 |
+
}`}
|
| 115 |
+
>
|
| 116 |
+
{isEvaluating ? (
|
| 117 |
+
<Loader2 className="w-5 h-5 animate-spin" />
|
| 118 |
+
) : (
|
| 119 |
+
<BarChart3 className="w-5 h-5" />
|
| 120 |
+
)}
|
| 121 |
+
{isEvaluating ? 'Running AI Agents...' : 'Run Evaluation'}
|
| 122 |
+
</button>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
{/* Right Column: Results */}
|
| 126 |
+
<div className="lg:col-span-8">
|
| 127 |
+
<div className="glass-card rounded-2xl min-h-[600px] relative overflow-hidden">
|
| 128 |
+
<AnimatePresence mode="wait">
|
| 129 |
+
{evaluations.length > 0 ? (
|
| 130 |
+
<motion.div
|
| 131 |
+
key="results"
|
| 132 |
+
initial={{ opacity: 0 }}
|
| 133 |
+
animate={{ opacity: 1 }}
|
| 134 |
+
exit={{ opacity: 0 }}
|
| 135 |
+
className="p-6"
|
| 136 |
+
>
|
| 137 |
+
<CandidateTable
|
| 138 |
+
evaluations={evaluations}
|
| 139 |
+
onViewDetail={(c) => setSelectedCandidate(c)}
|
| 140 |
+
/>
|
| 141 |
+
</motion.div>
|
| 142 |
+
) : (
|
| 143 |
+
<motion.div
|
| 144 |
+
key="empty"
|
| 145 |
+
initial={{ opacity: 0 }}
|
| 146 |
+
animate={{ opacity: 1 }}
|
| 147 |
+
exit={{ opacity: 0 }}
|
| 148 |
+
className="absolute inset-0 flex flex-col items-center justify-center text-slate-400 p-12 text-center"
|
| 149 |
+
>
|
| 150 |
+
<Search className="w-16 h-16 mb-4 opacity-20" />
|
| 151 |
+
<h3 className="text-xl font-medium">No Evaluations Yet</h3>
|
| 152 |
+
<p className="max-w-xs mt-2 text-sm">Upload a CSV, paste a JD, and hit Run to see the magic happen with Groq LLMs.</p>
|
| 153 |
+
</motion.div>
|
| 154 |
+
)}
|
| 155 |
+
</AnimatePresence>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
{/* Detail Modal */}
|
| 161 |
+
<AnimatePresence>
|
| 162 |
+
{selectedCandidate && (
|
| 163 |
+
<CandidateDetail
|
| 164 |
+
evaluation={selectedCandidate}
|
| 165 |
+
onClose={() => setSelectedCandidate(null)}
|
| 166 |
+
/>
|
| 167 |
+
)}
|
| 168 |
+
</AnimatePresence>
|
| 169 |
+
</div>
|
| 170 |
+
);
|
| 171 |
+
}
|
proj/frontend/src/components/CandidateDetail.jsx
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React, { useState } from 'react';
|
| 4 |
+
import { motion } from 'framer-motion';
|
| 5 |
+
import { X, ShieldCheck, Zap, Briefcase, Cpu, TrendingUp, Info, Search } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
export default function CandidateDetail({ evaluation, onClose }) {
|
| 8 |
+
const [activeTab, setActiveTab] = useState('Synthesis');
|
| 9 |
+
|
| 10 |
+
const agents = [
|
| 11 |
+
{ name: 'Synthesis', icon: ShieldCheck },
|
| 12 |
+
{ name: 'Signal Extraction', icon: Zap },
|
| 13 |
+
{ name: 'Explanation', icon: Info },
|
| 14 |
+
{ name: 'Founder Eval', icon: TrendingUp },
|
| 15 |
+
{ name: 'HR Agent', icon: Search },
|
| 16 |
+
{ name: 'Tech Agent', icon: Cpu },
|
| 17 |
+
{ name: 'Business Agent', icon: Briefcase },
|
| 18 |
+
];
|
| 19 |
+
|
| 20 |
+
const getAgentContent = (name) => {
|
| 21 |
+
if (name === 'Synthesis') return evaluation.synthesis;
|
| 22 |
+
const agent = evaluation.agent_outputs.find(a => a.agent_name === name);
|
| 23 |
+
return agent ? agent.content : 'No output available for this agent.';
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<motion.div
|
| 28 |
+
initial={{ opacity: 0 }}
|
| 29 |
+
animate={{ opacity: 1 }}
|
| 30 |
+
exit={{ opacity: 0 }}
|
| 31 |
+
className="fixed inset-0 z-[100] bg-black/40 backdrop-blur-sm flex justify-end"
|
| 32 |
+
onClick={onClose}
|
| 33 |
+
>
|
| 34 |
+
<motion.div
|
| 35 |
+
initial={{ x: '100%' }}
|
| 36 |
+
animate={{ x: 0 }}
|
| 37 |
+
exit={{ x: '100%' }}
|
| 38 |
+
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
| 39 |
+
className="w-full max-w-2xl bg-white dark:bg-slate-950 h-full shadow-2xl flex flex-col"
|
| 40 |
+
onClick={e => e.stopPropagation()}
|
| 41 |
+
>
|
| 42 |
+
{/* Header */}
|
| 43 |
+
<div className="p-8 border-b border-slate-100 dark:border-slate-800 flex justify-between items-start">
|
| 44 |
+
<div>
|
| 45 |
+
<span className="text-xs font-bold text-indigo-600 uppercase tracking-widest mb-1 block">Full Report</span>
|
| 46 |
+
<h2 className="text-3xl font-bold text-slate-900 dark:text-white">{evaluation.name}</h2>
|
| 47 |
+
<div className="flex items-center gap-4 mt-2">
|
| 48 |
+
<div className="flex items-center gap-1.5">
|
| 49 |
+
<div className="w-2 h-2 rounded-full bg-emerald-500"></div>
|
| 50 |
+
<span className="text-sm font-medium text-slate-500">{evaluation.decision}</span>
|
| 51 |
+
</div>
|
| 52 |
+
<div className="text-sm text-slate-400">Score: {Math.round(evaluation.final_score)}/100</div>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
<button
|
| 56 |
+
onClick={onClose}
|
| 57 |
+
className="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-full transition-colors"
|
| 58 |
+
>
|
| 59 |
+
<X className="w-6 h-6 text-slate-400" />
|
| 60 |
+
</button>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
{/* Content Area */}
|
| 64 |
+
<div className="flex flex-1 overflow-hidden">
|
| 65 |
+
{/* Sidebar Tabs */}
|
| 66 |
+
<div className="w-16 md:w-48 border-r border-slate-100 dark:border-slate-900 bg-slate-50/50 dark:bg-slate-900/20 py-6">
|
| 67 |
+
{agents.map((agent) => {
|
| 68 |
+
const Icon = agent.icon;
|
| 69 |
+
const isActive = activeTab === agent.name;
|
| 70 |
+
return (
|
| 71 |
+
<button
|
| 72 |
+
key={agent.name}
|
| 73 |
+
onClick={() => setActiveTab(agent.name)}
|
| 74 |
+
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium transition-all ${
|
| 75 |
+
isActive
|
| 76 |
+
? 'text-indigo-600 bg-white dark:bg-slate-900 shadow-sm border-r-2 border-indigo-600'
|
| 77 |
+
: 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'
|
| 78 |
+
}`}
|
| 79 |
+
>
|
| 80 |
+
<Icon className="w-5 h-5 flex-shrink-0" />
|
| 81 |
+
<span className="hidden md:block truncate">{agent.name}</span>
|
| 82 |
+
</button>
|
| 83 |
+
);
|
| 84 |
+
})}
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
{/* Main Content */}
|
| 88 |
+
<div className="flex-1 overflow-y-auto p-8">
|
| 89 |
+
<AnimatePresence mode="wait">
|
| 90 |
+
<motion.div
|
| 91 |
+
key={activeTab}
|
| 92 |
+
initial={{ opacity: 0, y: 10 }}
|
| 93 |
+
animate={{ opacity: 1, y: 0 }}
|
| 94 |
+
exit={{ opacity: 0, y: -10 }}
|
| 95 |
+
className="prose dark:prose-invert max-w-none"
|
| 96 |
+
>
|
| 97 |
+
<div className="mb-6 flex items-center justify-between">
|
| 98 |
+
<h3 className="text-xl font-bold m-0">{activeTab} Output</h3>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
{activeTab === 'Synthesis' ? (
|
| 102 |
+
<div className="space-y-6">
|
| 103 |
+
<div className="p-5 bg-indigo-50 dark:bg-indigo-900/20 rounded-2xl border border-indigo-100 dark:border-indigo-800">
|
| 104 |
+
<h4 className="text-indigo-900 dark:text-indigo-300 font-bold mb-2">Final Summary</h4>
|
| 105 |
+
<p className="text-sm leading-relaxed text-indigo-800 dark:text-indigo-400">{evaluation.synthesis}</p>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
<div className="grid grid-cols-2 gap-4">
|
| 109 |
+
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/10 rounded-xl border border-emerald-100 dark:border-emerald-800/50">
|
| 110 |
+
<h4 className="text-emerald-700 dark:text-emerald-400 font-bold text-sm mb-3">Key Strengths</h4>
|
| 111 |
+
<ul className="space-y-2">
|
| 112 |
+
{evaluation.strengths.map((s, i) => (
|
| 113 |
+
<li key={i} className="text-xs flex items-start gap-2 text-emerald-800/70 dark:text-emerald-500/70">
|
| 114 |
+
<span className="block w-1 h-1 rounded-full bg-emerald-400 mt-1.5 flex-shrink-0"></span>
|
| 115 |
+
{s}
|
| 116 |
+
</li>
|
| 117 |
+
))}
|
| 118 |
+
</ul>
|
| 119 |
+
</div>
|
| 120 |
+
<div className="p-4 bg-rose-50 dark:bg-rose-900/10 rounded-xl border border-rose-100 dark:border-rose-800/50">
|
| 121 |
+
<h4 className="text-rose-700 dark:text-rose-400 font-bold text-sm mb-3">Potential Risks</h4>
|
| 122 |
+
<ul className="space-y-2">
|
| 123 |
+
{evaluation.risks.map((r, i) => (
|
| 124 |
+
<li key={i} className="text-xs flex items-start gap-2 text-rose-800/70 dark:text-rose-500/70">
|
| 125 |
+
<span className="block w-1 h-1 rounded-full bg-rose-400 mt-1.5 flex-shrink-0"></span>
|
| 126 |
+
{r}
|
| 127 |
+
</li>
|
| 128 |
+
))}
|
| 129 |
+
</ul>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<div className="grid grid-cols-5 gap-2">
|
| 134 |
+
{Object.entries(evaluation.scores).map(([key, val]) => (
|
| 135 |
+
<div key={key} className="p-3 bg-slate-50 dark:bg-slate-900 rounded-lg text-center">
|
| 136 |
+
<div className="text-[10px] uppercase text-slate-400 font-bold mb-1">{key}</div>
|
| 137 |
+
<div className="text-sm font-bold text-slate-700 dark:text-slate-300">{val}</div>
|
| 138 |
+
</div>
|
| 139 |
+
))}
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
) : (
|
| 143 |
+
<div className="bg-slate-50 dark:bg-slate-900/50 p-6 rounded-2xl whitespace-pre-wrap text-sm leading-relaxed text-slate-600 dark:text-slate-400 border border-slate-100 dark:border-slate-800">
|
| 144 |
+
{getAgentContent(activeTab)}
|
| 145 |
+
</div>
|
| 146 |
+
)}
|
| 147 |
+
</motion.div>
|
| 148 |
+
</AnimatePresence>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
</motion.div>
|
| 152 |
+
</motion.div>
|
| 153 |
+
);
|
| 154 |
+
}
|
proj/frontend/src/components/CandidateTable.jsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React from 'react';
|
| 4 |
+
import { ChevronRight, ArrowUpRight, ShieldCheck, AlertTriangle, Target } from 'lucide-react';
|
| 5 |
+
|
| 6 |
+
export default function CandidateTable({ evaluations, onViewDetail }) {
|
| 7 |
+
if (!evaluations || evaluations.length === 0) return null;
|
| 8 |
+
|
| 9 |
+
return (
|
| 10 |
+
<div className="overflow-x-auto">
|
| 11 |
+
<table className="w-full text-left border-separate border-spacing-y-3">
|
| 12 |
+
<thead>
|
| 13 |
+
<tr className="text-slate-500 text-sm font-medium">
|
| 14 |
+
<th className="px-4 pb-2">Rank</th>
|
| 15 |
+
<th className="px-4 pb-2">Candidate</th>
|
| 16 |
+
<th className="px-4 pb-2">Score</th>
|
| 17 |
+
<th className="px-4 pb-2">Decision</th>
|
| 18 |
+
<th className="px-4 pb-2">Confidence</th>
|
| 19 |
+
<th className="px-4 pb-2 text-right">Action</th>
|
| 20 |
+
</tr>
|
| 21 |
+
</thead>
|
| 22 |
+
<tbody>
|
| 23 |
+
{evaluations.map((evalItem, index) => (
|
| 24 |
+
<tr
|
| 25 |
+
key={evalItem.candidate_id}
|
| 26 |
+
className="glass-card hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-all group cursor-pointer"
|
| 27 |
+
onClick={() => onViewDetail(evalItem)}
|
| 28 |
+
>
|
| 29 |
+
<td className="px-4 py-4 rounded-l-xl">
|
| 30 |
+
<span className={`flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold ${
|
| 31 |
+
index === 0 ? 'bg-amber-100 text-amber-600' :
|
| 32 |
+
index === 1 ? 'bg-slate-100 text-slate-600' :
|
| 33 |
+
index === 2 ? 'bg-orange-100 text-orange-600' : 'bg-slate-50 text-slate-400'
|
| 34 |
+
}`}>
|
| 35 |
+
{index + 1}
|
| 36 |
+
</span>
|
| 37 |
+
</td>
|
| 38 |
+
<td className="px-4 py-4">
|
| 39 |
+
<div>
|
| 40 |
+
<div className="font-semibold text-slate-900 dark:text-white">{evalItem.name}</div>
|
| 41 |
+
<div className="text-xs text-slate-500 truncate max-w-[150px]">{evalItem.candidate_id}</div>
|
| 42 |
+
</div>
|
| 43 |
+
</td>
|
| 44 |
+
<td className="px-4 py-4">
|
| 45 |
+
<div className="flex items-center gap-2">
|
| 46 |
+
<div className="w-16 h-2 bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden">
|
| 47 |
+
<div
|
| 48 |
+
className="h-full bg-indigo-500 transition-all duration-1000"
|
| 49 |
+
style={{ width: `${evalItem.final_score}%` }}
|
| 50 |
+
/>
|
| 51 |
+
</div>
|
| 52 |
+
<span className="text-sm font-bold text-indigo-600">{Math.round(evalItem.final_score)}</span>
|
| 53 |
+
</div>
|
| 54 |
+
</td>
|
| 55 |
+
<td className="px-4 py-4">
|
| 56 |
+
<span className={`px-2 py-1 rounded-md text-[10px] uppercase font-bold tracking-wider ${
|
| 57 |
+
evalItem.decision === 'Strong Hire' ? 'bg-emerald-100 text-emerald-700' :
|
| 58 |
+
evalItem.decision === 'Hire' ? 'bg-blue-100 text-blue-700' :
|
| 59 |
+
evalItem.decision === 'Reject' ? 'bg-rose-100 text-rose-700' : 'bg-slate-100 text-slate-700'
|
| 60 |
+
}`}>
|
| 61 |
+
{evalItem.decision}
|
| 62 |
+
</span>
|
| 63 |
+
</td>
|
| 64 |
+
<td className="px-4 py-4">
|
| 65 |
+
<div className="flex items-center gap-1.6 text-sm">
|
| 66 |
+
<Target className="w-4 h-4 text-slate-400" />
|
| 67 |
+
<span>{evalItem.confidence}%</span>
|
| 68 |
+
</div>
|
| 69 |
+
</td>
|
| 70 |
+
<td className="px-4 py-4 text-right rounded-r-xl">
|
| 71 |
+
<button className="p-2 hover:bg-white dark:hover:bg-slate-700 rounded-lg transition-colors">
|
| 72 |
+
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-indigo-600" />
|
| 73 |
+
</button>
|
| 74 |
+
</td>
|
| 75 |
+
</tr>
|
| 76 |
+
))}
|
| 77 |
+
</tbody>
|
| 78 |
+
</table>
|
| 79 |
+
</div>
|
| 80 |
+
);
|
| 81 |
+
}
|
proj/frontend/src/lib/api.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios';
|
| 2 |
+
|
| 3 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
| 4 |
+
|
| 5 |
+
const api = axios.create({
|
| 6 |
+
baseURL: API_URL,
|
| 7 |
+
});
|
| 8 |
+
|
| 9 |
+
export const uploadCSV = async (file) => {
|
| 10 |
+
const formData = new FormData();
|
| 11 |
+
formData.append('file', file);
|
| 12 |
+
const response = await api.post('/upload-csv', formData);
|
| 13 |
+
return response.data;
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export const runEvaluation = async (jd, candidates) => {
|
| 17 |
+
const response = await api.post('/evaluate', { jd, candidates });
|
| 18 |
+
return response.data;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
export const getCandidateDetails = async (id) => {
|
| 22 |
+
const response = await api.get(`/candidate/${id}`);
|
| 23 |
+
return response.data;
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export default api;
|
proj/frontend/src/lib/api.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios';
|
| 2 |
+
|
| 3 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
| 4 |
+
|
| 5 |
+
const api = axios.create({
|
| 6 |
+
baseURL: API_URL,
|
| 7 |
+
});
|
| 8 |
+
|
| 9 |
+
export const uploadCSV = async (file: File) => {
|
| 10 |
+
const formData = new FormData();
|
| 11 |
+
formData.append('file', file);
|
| 12 |
+
const response = await api.post('/upload-csv', formData);
|
| 13 |
+
return response.data;
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export const runEvaluation = async (jd: string, candidates: any[]) => {
|
| 17 |
+
const response = await api.post('/evaluate', { jd, candidates });
|
| 18 |
+
return response.data;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
export const getCandidateDetails = async (id: string) => {
|
| 22 |
+
const response = await api.get(`/candidate/${id}`);
|
| 23 |
+
return response.data;
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export default api;
|