Spaces:
Build error
Build error
Commit ·
e663c0c
1
Parent(s): 31fe301
Deploy FastAPI backend for AI recruitment system
Browse files- Add Dockerfile for HF Spaces (port 7860)
- Add FastAPI app with jobs, applications, hr routes
- Add AI resume analysis with OpenAI gpt-4o-mini
- Add automated email notifications (selection/rejection)
- Fix env var loading to use system environment (HF Spaces secrets)
- .gitignore +7 -0
- Dockerfile +19 -0
- README.md +14 -3
- agent/__init__.py +0 -0
- agent/resume_agent.py +101 -0
- agent/tools.py +147 -0
- email_service.py +320 -0
- main.py +65 -0
- requirements.txt +9 -0
- routes/__init__.py +0 -0
- routes/applications.py +249 -0
- routes/hr.py +36 -0
- routes/jobs.py +111 -0
- supabase_client.py +13 -0
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*.pyo
|
| 4 |
+
.env
|
| 5 |
+
*.log
|
| 6 |
+
.venv/
|
| 7 |
+
venv/
|
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system dependencies for pdfplumber
|
| 6 |
+
RUN apt-get update && apt-get install -y \
|
| 7 |
+
libpoppler-cpp-dev \
|
| 8 |
+
poppler-utils \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
# Hugging Face Spaces uses port 7860
|
| 17 |
+
EXPOSE 7860
|
| 18 |
+
|
| 19 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,21 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: SmartHire AI Backend
|
| 3 |
+
emoji: 🤖
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# SmartHire AI – FastAPI Backend
|
| 11 |
+
|
| 12 |
+
AI-powered recruitment system backend. Handles resume screening, ATS scoring, and automated email notifications.
|
| 13 |
+
|
| 14 |
+
## Endpoints
|
| 15 |
+
- `GET /` — Health check
|
| 16 |
+
- `POST /api/jobs/create` — Create job posting
|
| 17 |
+
- `GET /api/jobs/{job_id}` — Get job details
|
| 18 |
+
- `GET /api/jobs/hr/{hr_id}` — Get HR's jobs
|
| 19 |
+
- `POST /api/applications/submit` — Submit application with resume PDF
|
| 20 |
+
- `GET /api/applications/{job_id}` — Get applications for a job
|
| 21 |
+
- `POST /api/hr/register` — Register HR user
|
agent/__init__.py
ADDED
|
File without changes
|
agent/resume_agent.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import io
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
import pdfplumber
|
| 7 |
+
from openai import AsyncOpenAI
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def extract_text_from_pdf(pdf_bytes: bytes) -> str:
|
| 18 |
+
text = ""
|
| 19 |
+
try:
|
| 20 |
+
with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
|
| 21 |
+
for page in pdf.pages:
|
| 22 |
+
page_text = page.extract_text()
|
| 23 |
+
if page_text:
|
| 24 |
+
text += page_text + "\n"
|
| 25 |
+
except Exception as exc:
|
| 26 |
+
logger.error("Failed to extract text from PDF: %s", exc)
|
| 27 |
+
raise ValueError(f"Could not extract text from PDF: {exc}") from exc
|
| 28 |
+
return text.strip()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
async def analyze_resume(
|
| 32 |
+
pdf_bytes: bytes,
|
| 33 |
+
job_title: str,
|
| 34 |
+
job_description: str,
|
| 35 |
+
required_skills: list,
|
| 36 |
+
experience_years: int,
|
| 37 |
+
company_name: str
|
| 38 |
+
) -> dict:
|
| 39 |
+
resume_text = extract_text_from_pdf(pdf_bytes)
|
| 40 |
+
if not resume_text:
|
| 41 |
+
raise ValueError("Could not extract text from PDF – the file may be image-only or corrupted.")
|
| 42 |
+
|
| 43 |
+
skills_str = ", ".join(required_skills)
|
| 44 |
+
|
| 45 |
+
system_prompt = """You are an expert AI recruitment screener. Analyze the resume against the job requirements and return ONLY a valid JSON object — no markdown, no extra text.
|
| 46 |
+
|
| 47 |
+
JSON structure:
|
| 48 |
+
{
|
| 49 |
+
"status": "selected" or "rejected",
|
| 50 |
+
"ats_score": <integer 0-100>,
|
| 51 |
+
"candidate_name": "<full name from resume>",
|
| 52 |
+
"missing_skills": ["skill1", "skill2"],
|
| 53 |
+
"ai_feedback": "<2-3 sentence professional feedback>",
|
| 54 |
+
"skill_recommendations": [
|
| 55 |
+
{
|
| 56 |
+
"skill": "<missing skill>",
|
| 57 |
+
"why_it_matters": "<why this skill is important for the role>",
|
| 58 |
+
"resource_link": "<free learning URL from Coursera/YouTube/freeCodeCamp/official docs>",
|
| 59 |
+
"time_to_learn": "<realistic duration>"
|
| 60 |
+
}
|
| 61 |
+
]
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
Scoring rules:
|
| 65 |
+
- ATS score 0-100 based on: skill match (50%), experience (25%), education (15%), resume quality (10%)
|
| 66 |
+
- Select if ats_score >= 70, reject otherwise
|
| 67 |
+
- skill_recommendations only for missing skills (empty array if selected with all skills)"""
|
| 68 |
+
|
| 69 |
+
user_prompt = f"""JOB: {job_title} at {company_name}
|
| 70 |
+
REQUIRED SKILLS: {skills_str}
|
| 71 |
+
MIN EXPERIENCE: {experience_years} years
|
| 72 |
+
DESCRIPTION: {job_description[:800]}
|
| 73 |
+
|
| 74 |
+
RESUME:
|
| 75 |
+
{resume_text[:3000]}
|
| 76 |
+
|
| 77 |
+
Analyze and return JSON only."""
|
| 78 |
+
|
| 79 |
+
logger.info("Starting resume analysis for: %s at %s", job_title, company_name)
|
| 80 |
+
|
| 81 |
+
response = await client.chat.completions.create(
|
| 82 |
+
model="gpt-4o-mini",
|
| 83 |
+
messages=[
|
| 84 |
+
{"role": "system", "content": system_prompt},
|
| 85 |
+
{"role": "user", "content": user_prompt},
|
| 86 |
+
],
|
| 87 |
+
temperature=0.2,
|
| 88 |
+
max_tokens=1200,
|
| 89 |
+
response_format={"type": "json_object"},
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
output = response.choices[0].message.content or "{}"
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
parsed = json.loads(output)
|
| 96 |
+
except json.JSONDecodeError as exc:
|
| 97 |
+
logger.error("Invalid JSON from OpenAI: %s", output[:300])
|
| 98 |
+
raise ValueError(f"Invalid JSON response: {exc}") from exc
|
| 99 |
+
|
| 100 |
+
logger.info("Analysis done — status: %s, score: %s", parsed.get("status"), parsed.get("ats_score"))
|
| 101 |
+
return parsed
|
agent/tools.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from agents import function_tool
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@function_tool
|
| 9 |
+
async def extract_resume_info(resume_text: str) -> str:
|
| 10 |
+
"""
|
| 11 |
+
Extract structured information from raw resume text.
|
| 12 |
+
Returns a JSON string with candidate name, skills, experience years,
|
| 13 |
+
education level, and certifications.
|
| 14 |
+
"""
|
| 15 |
+
result = {
|
| 16 |
+
"candidate_name": "Unknown",
|
| 17 |
+
"skills": [],
|
| 18 |
+
"experience_years": 0,
|
| 19 |
+
"education": "Unknown",
|
| 20 |
+
"certifications": []
|
| 21 |
+
}
|
| 22 |
+
logger.debug("extract_resume_info called with %d chars of resume text", len(resume_text))
|
| 23 |
+
return json.dumps(result)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@function_tool
|
| 27 |
+
async def match_skills(resume_skills: str, required_skills: str) -> str:
|
| 28 |
+
"""
|
| 29 |
+
Compare resume skills against required job skills.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
resume_skills: comma-separated list of skills found in the resume
|
| 33 |
+
required_skills: comma-separated list of skills required for the job
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
JSON string with matched_skills, missing_skills, and match_percentage
|
| 37 |
+
"""
|
| 38 |
+
resume_set = {s.strip().lower() for s in resume_skills.split(",") if s.strip()}
|
| 39 |
+
required_set = {s.strip().lower() for s in required_skills.split(",") if s.strip()}
|
| 40 |
+
|
| 41 |
+
if not required_set:
|
| 42 |
+
return json.dumps({
|
| 43 |
+
"matched_skills": [],
|
| 44 |
+
"missing_skills": [],
|
| 45 |
+
"match_percentage": 0.0
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
matched = sorted(resume_set & required_set)
|
| 49 |
+
missing = sorted(required_set - resume_set)
|
| 50 |
+
match_percentage = round((len(matched) / len(required_set)) * 100, 2)
|
| 51 |
+
|
| 52 |
+
result = {
|
| 53 |
+
"matched_skills": matched,
|
| 54 |
+
"missing_skills": missing,
|
| 55 |
+
"match_percentage": match_percentage
|
| 56 |
+
}
|
| 57 |
+
logger.debug("match_skills: %d/%d matched (%.1f%%)", len(matched), len(required_set), match_percentage)
|
| 58 |
+
return json.dumps(result)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@function_tool
|
| 62 |
+
async def calculate_ats_score(
|
| 63 |
+
skill_match_percentage: float,
|
| 64 |
+
experience_years_candidate: int,
|
| 65 |
+
experience_years_required: int,
|
| 66 |
+
education_level: str,
|
| 67 |
+
resume_quality: str
|
| 68 |
+
) -> str:
|
| 69 |
+
"""
|
| 70 |
+
Calculate ATS score (0–100) based on multiple criteria.
|
| 71 |
+
"""
|
| 72 |
+
skill_match_percentage = max(0.0, min(100.0, float(skill_match_percentage)))
|
| 73 |
+
skill_score = round(50 * skill_match_percentage / 100, 2)
|
| 74 |
+
|
| 75 |
+
if experience_years_candidate >= experience_years_required:
|
| 76 |
+
experience_score = 25
|
| 77 |
+
elif experience_years_required > 0 and experience_years_candidate >= experience_years_required / 2:
|
| 78 |
+
experience_score = 12
|
| 79 |
+
else:
|
| 80 |
+
experience_score = 0
|
| 81 |
+
|
| 82 |
+
education_map = {
|
| 83 |
+
"phd": 15, "masters": 15, "master": 15,
|
| 84 |
+
"bachelors": 12, "bachelor": 12, "diploma": 8,
|
| 85 |
+
}
|
| 86 |
+
education_score = education_map.get(education_level.strip().lower(), 4)
|
| 87 |
+
|
| 88 |
+
quality_map = {"good": 10, "average": 6, "poor": 2}
|
| 89 |
+
quality_score = quality_map.get(resume_quality.strip().lower(), 6)
|
| 90 |
+
|
| 91 |
+
ats_score = round(skill_score + experience_score + education_score + quality_score, 2)
|
| 92 |
+
ats_score = max(0, min(100, ats_score))
|
| 93 |
+
|
| 94 |
+
result = {
|
| 95 |
+
"ats_score": ats_score,
|
| 96 |
+
"breakdown": {
|
| 97 |
+
"skill_score": skill_score,
|
| 98 |
+
"experience_score": experience_score,
|
| 99 |
+
"education_score": education_score,
|
| 100 |
+
"quality_score": quality_score
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
logger.debug("calculate_ats_score: total=%.2f", ats_score)
|
| 104 |
+
return json.dumps(result)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@function_tool
|
| 108 |
+
async def make_decision(
|
| 109 |
+
ats_score: int,
|
| 110 |
+
candidate_name: str,
|
| 111 |
+
missing_skills: str,
|
| 112 |
+
job_title: str
|
| 113 |
+
) -> str:
|
| 114 |
+
"""
|
| 115 |
+
Make a hiring decision based on ATS score.
|
| 116 |
+
"""
|
| 117 |
+
missing_list = [s.strip() for s in missing_skills.split(",") if s.strip()]
|
| 118 |
+
|
| 119 |
+
if ats_score >= 70:
|
| 120 |
+
status = "selected"
|
| 121 |
+
recommendation_message = (
|
| 122 |
+
f"Congratulations, {candidate_name}! Your profile is a strong match for the "
|
| 123 |
+
f"{job_title} position. Our recruitment team will be in touch with next steps shortly."
|
| 124 |
+
)
|
| 125 |
+
else:
|
| 126 |
+
status = "rejected"
|
| 127 |
+
if missing_list:
|
| 128 |
+
skills_str = ", ".join(missing_list)
|
| 129 |
+
recommendation_message = (
|
| 130 |
+
f"Thank you for applying, {candidate_name}. Unfortunately your profile did not meet "
|
| 131 |
+
f"the minimum requirements for the {job_title} role at this time. "
|
| 132 |
+
f"We recommend strengthening the following areas: {skills_str}."
|
| 133 |
+
)
|
| 134 |
+
else:
|
| 135 |
+
recommendation_message = (
|
| 136 |
+
f"Thank you for applying, {candidate_name}. Unfortunately your profile did not meet "
|
| 137 |
+
f"the minimum requirements for the {job_title} role at this time."
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
result = {
|
| 141 |
+
"status": status,
|
| 142 |
+
"ats_score": ats_score,
|
| 143 |
+
"missing_skills": missing_list,
|
| 144 |
+
"recommendation_message": recommendation_message
|
| 145 |
+
}
|
| 146 |
+
logger.debug("make_decision: %s (score=%d)", status, ats_score)
|
| 147 |
+
return json.dumps(result)
|
email_service.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import smtplib
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
+
from email.mime.multipart import MIMEMultipart
|
| 5 |
+
from email.mime.text import MIMEText
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
| 11 |
+
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
| 12 |
+
SMTP_EMAIL = os.getenv("SMTP_EMAIL")
|
| 13 |
+
SMTP_APP_PASSWORD = os.getenv("SMTP_APP_PASSWORD")
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _send_email(to_email: str, subject: str, html_body: str) -> bool:
|
| 19 |
+
"""Core email sending function with one retry on failure."""
|
| 20 |
+
for attempt in range(2):
|
| 21 |
+
try:
|
| 22 |
+
msg = MIMEMultipart("alternative")
|
| 23 |
+
msg["Subject"] = subject
|
| 24 |
+
msg["From"] = SMTP_EMAIL
|
| 25 |
+
msg["To"] = to_email
|
| 26 |
+
|
| 27 |
+
part = MIMEText(html_body, "html")
|
| 28 |
+
msg.attach(part)
|
| 29 |
+
|
| 30 |
+
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
|
| 31 |
+
server.ehlo()
|
| 32 |
+
server.starttls()
|
| 33 |
+
server.login(SMTP_EMAIL, SMTP_APP_PASSWORD)
|
| 34 |
+
server.sendmail(SMTP_EMAIL, to_email, msg.as_string())
|
| 35 |
+
|
| 36 |
+
logger.info(f"Email sent successfully to {to_email} | Subject: {subject}")
|
| 37 |
+
return True
|
| 38 |
+
|
| 39 |
+
except smtplib.SMTPException as e:
|
| 40 |
+
logger.error(f"SMTP error on attempt {attempt + 1} sending to {to_email}: {e}")
|
| 41 |
+
if attempt == 1:
|
| 42 |
+
return False
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger.error(f"Unexpected error on attempt {attempt + 1} sending to {to_email}: {e}")
|
| 45 |
+
if attempt == 1:
|
| 46 |
+
return False
|
| 47 |
+
|
| 48 |
+
return False
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def send_rejection_email(
|
| 52 |
+
candidate_name: str,
|
| 53 |
+
candidate_email: str,
|
| 54 |
+
job_title: str,
|
| 55 |
+
company_name: str,
|
| 56 |
+
ats_score: float,
|
| 57 |
+
missing_skills_with_recommendations: list
|
| 58 |
+
) -> bool:
|
| 59 |
+
subject = f"Your Application for {job_title} at {company_name} – Update"
|
| 60 |
+
|
| 61 |
+
skills_rows_html = ""
|
| 62 |
+
if missing_skills_with_recommendations:
|
| 63 |
+
for item in missing_skills_with_recommendations:
|
| 64 |
+
skill = item.get("skill", "")
|
| 65 |
+
why = item.get("why_it_matters", "")
|
| 66 |
+
link = item.get("resource_link", "#")
|
| 67 |
+
time_to_learn = item.get("time_to_learn", "")
|
| 68 |
+
skills_rows_html += f"""
|
| 69 |
+
<tr>
|
| 70 |
+
<td style="padding:10px 12px; border-bottom:1px solid #f0f0f0; color:#333; font-weight:600;">{skill}</td>
|
| 71 |
+
<td style="padding:10px 12px; border-bottom:1px solid #f0f0f0; color:#555;">{why}</td>
|
| 72 |
+
<td style="padding:10px 12px; border-bottom:1px solid #f0f0f0;">
|
| 73 |
+
<a href="{link}" style="color:#4F46E5; text-decoration:none; font-weight:500;">Learn Now →</a>
|
| 74 |
+
</td>
|
| 75 |
+
<td style="padding:10px 12px; border-bottom:1px solid #f0f0f0; color:#888; text-align:center;">{time_to_learn}</td>
|
| 76 |
+
</tr>"""
|
| 77 |
+
|
| 78 |
+
skills_table_html = ""
|
| 79 |
+
if skills_rows_html:
|
| 80 |
+
skills_table_html = f"""
|
| 81 |
+
<div style="margin-top:28px;">
|
| 82 |
+
<h3 style="font-size:16px; color:#1a1a2e; margin-bottom:12px; font-weight:700;">
|
| 83 |
+
Recommended Skills to Strengthen Your Profile
|
| 84 |
+
</h3>
|
| 85 |
+
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse; background:#fafafa; border-radius:8px; overflow:hidden;">
|
| 86 |
+
<thead>
|
| 87 |
+
<tr style="background:#4F46E5;">
|
| 88 |
+
<th style="padding:10px 12px; color:#fff; text-align:left; font-size:13px;">Skill</th>
|
| 89 |
+
<th style="padding:10px 12px; color:#fff; text-align:left; font-size:13px;">Why It Matters</th>
|
| 90 |
+
<th style="padding:10px 12px; color:#fff; text-align:left; font-size:13px;">Resource</th>
|
| 91 |
+
<th style="padding:10px 12px; color:#fff; text-align:center; font-size:13px;">Time to Learn</th>
|
| 92 |
+
</tr>
|
| 93 |
+
</thead>
|
| 94 |
+
<tbody>
|
| 95 |
+
{skills_rows_html}
|
| 96 |
+
</tbody>
|
| 97 |
+
</table>
|
| 98 |
+
</div>"""
|
| 99 |
+
|
| 100 |
+
html_body = f"""<!DOCTYPE html>
|
| 101 |
+
<html lang="en">
|
| 102 |
+
<head>
|
| 103 |
+
<meta charset="UTF-8">
|
| 104 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 105 |
+
<title>Application Update</title>
|
| 106 |
+
</head>
|
| 107 |
+
<body style="margin:0; padding:0; background-color:#f4f6fb; font-family:'Segoe UI', Arial, sans-serif;">
|
| 108 |
+
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f6fb; padding:40px 0;">
|
| 109 |
+
<tr>
|
| 110 |
+
<td align="center">
|
| 111 |
+
<table width="620" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 4px 20px rgba(0,0,0,0.08);">
|
| 112 |
+
<tr>
|
| 113 |
+
<td style="background:linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding:36px 40px; text-align:center;">
|
| 114 |
+
<h1 style="margin:0; color:#ffffff; font-size:24px; font-weight:700; letter-spacing:0.5px;">{company_name}</h1>
|
| 115 |
+
<p style="margin:8px 0 0; color:rgba(255,255,255,0.85); font-size:14px;">Recruitment Team</p>
|
| 116 |
+
</td>
|
| 117 |
+
</tr>
|
| 118 |
+
<tr>
|
| 119 |
+
<td style="padding:40px;">
|
| 120 |
+
<p style="font-size:16px; color:#333; margin:0 0 16px;">Dear <strong>{candidate_name}</strong>,</p>
|
| 121 |
+
<p style="font-size:15px; color:#555; line-height:1.7; margin:0 0 16px;">
|
| 122 |
+
Thank you for taking the time to apply for the <strong>{job_title}</strong> position at <strong>{company_name}</strong>.
|
| 123 |
+
</p>
|
| 124 |
+
<p style="font-size:15px; color:#555; line-height:1.7; margin:0 0 24px;">
|
| 125 |
+
After a thorough review, we regret to inform you that we will not be moving forward with your application at this time.
|
| 126 |
+
</p>
|
| 127 |
+
<div style="background:#fef3f2; border:1px solid #fecaca; border-radius:10px; padding:20px 24px; margin-bottom:24px; text-align:center;">
|
| 128 |
+
<p style="margin:0 0 6px; font-size:13px; color:#888; text-transform:uppercase; letter-spacing:1px; font-weight:600;">Your ATS Match Score</p>
|
| 129 |
+
<p style="margin:0; font-size:42px; font-weight:800; color:#e53e3e;">{int(ats_score)}<span style="font-size:20px; font-weight:500; color:#999;">/ 100</span></p>
|
| 130 |
+
<p style="margin:6px 0 0; font-size:13px; color:#999;">A score of 70 or above is required to proceed</p>
|
| 131 |
+
</div>
|
| 132 |
+
{skills_table_html}
|
| 133 |
+
<p style="font-size:15px; color:#555; line-height:1.7; margin:28px 0 32px;">
|
| 134 |
+
We strongly encourage you to work on the skills listed above and apply again in the future.
|
| 135 |
+
</p>
|
| 136 |
+
<p style="font-size:15px; color:#333; margin:0;">
|
| 137 |
+
Warm regards,<br>
|
| 138 |
+
<strong>The Recruitment Team</strong><br>
|
| 139 |
+
<span style="color:#888; font-size:13px;">{company_name}</span>
|
| 140 |
+
</p>
|
| 141 |
+
</td>
|
| 142 |
+
</tr>
|
| 143 |
+
<tr>
|
| 144 |
+
<td style="background:#f8f9fc; padding:20px 40px; text-align:center; border-top:1px solid #eee;">
|
| 145 |
+
<p style="margin:0; font-size:12px; color:#aaa;">This is an automated message. Please do not reply directly to this email.</p>
|
| 146 |
+
</td>
|
| 147 |
+
</tr>
|
| 148 |
+
</table>
|
| 149 |
+
</td>
|
| 150 |
+
</tr>
|
| 151 |
+
</table>
|
| 152 |
+
</body>
|
| 153 |
+
</html>"""
|
| 154 |
+
|
| 155 |
+
return _send_email(candidate_email, subject, html_body)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def send_selection_email(
|
| 159 |
+
candidate_name: str,
|
| 160 |
+
candidate_email: str,
|
| 161 |
+
job_title: str,
|
| 162 |
+
company_name: str,
|
| 163 |
+
ats_score: float
|
| 164 |
+
) -> bool:
|
| 165 |
+
subject = f"Congratulations! You've Been Selected – {job_title} at {company_name}"
|
| 166 |
+
|
| 167 |
+
html_body = f"""<!DOCTYPE html>
|
| 168 |
+
<html lang="en">
|
| 169 |
+
<head>
|
| 170 |
+
<meta charset="UTF-8">
|
| 171 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 172 |
+
<title>Congratulations!</title>
|
| 173 |
+
</head>
|
| 174 |
+
<body style="margin:0; padding:0; background-color:#f4f6fb; font-family:'Segoe UI', Arial, sans-serif;">
|
| 175 |
+
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f6fb; padding:40px 0;">
|
| 176 |
+
<tr>
|
| 177 |
+
<td align="center">
|
| 178 |
+
<table width="620" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 4px 20px rgba(0,0,0,0.08);">
|
| 179 |
+
<tr>
|
| 180 |
+
<td style="background:linear-gradient(135deg, #11998e 0%, #38ef7d 100%); padding:36px 40px; text-align:center;">
|
| 181 |
+
<div style="font-size:48px; margin-bottom:12px;">🎉</div>
|
| 182 |
+
<h1 style="margin:0; color:#ffffff; font-size:26px; font-weight:700;">Congratulations!</h1>
|
| 183 |
+
<p style="margin:8px 0 0; color:rgba(255,255,255,0.9); font-size:15px;">You have been selected!</p>
|
| 184 |
+
</td>
|
| 185 |
+
</tr>
|
| 186 |
+
<tr>
|
| 187 |
+
<td style="padding:40px;">
|
| 188 |
+
<p style="font-size:16px; color:#333; margin:0 0 16px;">Dear <strong>{candidate_name}</strong>,</p>
|
| 189 |
+
<p style="font-size:15px; color:#555; line-height:1.7; margin:0 0 16px;">
|
| 190 |
+
We are thrilled to inform you that you have been <strong style="color:#11998e;">selected</strong> for the
|
| 191 |
+
<strong>{job_title}</strong> position at <strong>{company_name}</strong>!
|
| 192 |
+
</p>
|
| 193 |
+
<div style="background:#f0fdf4; border:1px solid #86efac; border-radius:10px; padding:20px 24px; margin-bottom:28px; text-align:center;">
|
| 194 |
+
<p style="margin:0 0 6px; font-size:13px; color:#888; text-transform:uppercase; letter-spacing:1px; font-weight:600;">Your ATS Match Score</p>
|
| 195 |
+
<p style="margin:0; font-size:42px; font-weight:800; color:#16a34a;">{int(ats_score)}<span style="font-size:20px; font-weight:500; color:#999;">/ 100</span></p>
|
| 196 |
+
</div>
|
| 197 |
+
<div style="background:#f8f9fc; border-left:4px solid #11998e; border-radius:0 8px 8px 0; padding:20px 24px; margin-bottom:28px;">
|
| 198 |
+
<h3 style="margin:0 0 12px; font-size:15px; color:#1a1a2e; font-weight:700;">What Happens Next?</h3>
|
| 199 |
+
<ul style="margin:0; padding-left:20px; color:#555; font-size:14px; line-height:2;">
|
| 200 |
+
<li>Our HR team will review your application in detail</li>
|
| 201 |
+
<li>You will be contacted within <strong>2–3 business days</strong> to schedule an interview</li>
|
| 202 |
+
<li>Please ensure your contact details are up to date</li>
|
| 203 |
+
</ul>
|
| 204 |
+
</div>
|
| 205 |
+
<p style="font-size:15px; color:#333; margin:0;">
|
| 206 |
+
Best regards,<br>
|
| 207 |
+
<strong>The Recruitment Team</strong><br>
|
| 208 |
+
<span style="color:#888; font-size:13px;">{company_name}</span>
|
| 209 |
+
</p>
|
| 210 |
+
</td>
|
| 211 |
+
</tr>
|
| 212 |
+
<tr>
|
| 213 |
+
<td style="background:#f8f9fc; padding:20px 40px; text-align:center; border-top:1px solid #eee;">
|
| 214 |
+
<p style="margin:0; font-size:12px; color:#aaa;">This is an automated message. Please do not reply directly to this email.</p>
|
| 215 |
+
</td>
|
| 216 |
+
</tr>
|
| 217 |
+
</table>
|
| 218 |
+
</td>
|
| 219 |
+
</tr>
|
| 220 |
+
</table>
|
| 221 |
+
</body>
|
| 222 |
+
</html>"""
|
| 223 |
+
|
| 224 |
+
return _send_email(candidate_email, subject, html_body)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def send_hr_notification_email(
|
| 228 |
+
hr_email: str,
|
| 229 |
+
candidate_name: str,
|
| 230 |
+
candidate_email: str,
|
| 231 |
+
job_title: str,
|
| 232 |
+
company_name: str,
|
| 233 |
+
ats_score: float,
|
| 234 |
+
applied_at: str
|
| 235 |
+
) -> bool:
|
| 236 |
+
subject = f"New Application Received – {job_title} | ATS Score: {int(ats_score)}"
|
| 237 |
+
|
| 238 |
+
score_color = "#16a34a" if ats_score >= 70 else "#e53e3e"
|
| 239 |
+
status_label = "SELECTED" if ats_score >= 70 else "REJECTED"
|
| 240 |
+
status_bg = "#f0fdf4" if ats_score >= 70 else "#fef3f2"
|
| 241 |
+
status_border = "#86efac" if ats_score >= 70 else "#fecaca"
|
| 242 |
+
|
| 243 |
+
html_body = f"""<!DOCTYPE html>
|
| 244 |
+
<html lang="en">
|
| 245 |
+
<head>
|
| 246 |
+
<meta charset="UTF-8">
|
| 247 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 248 |
+
<title>New Application Notification</title>
|
| 249 |
+
</head>
|
| 250 |
+
<body style="margin:0; padding:0; background-color:#f4f6fb; font-family:'Segoe UI', Arial, sans-serif;">
|
| 251 |
+
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f6fb; padding:40px 0;">
|
| 252 |
+
<tr>
|
| 253 |
+
<td align="center">
|
| 254 |
+
<table width="620" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 4px 20px rgba(0,0,0,0.08);">
|
| 255 |
+
<tr>
|
| 256 |
+
<td style="background:linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); padding:30px 40px; text-align:center;">
|
| 257 |
+
<h1 style="margin:0; color:#ffffff; font-size:20px; font-weight:700;">📋 New Application Alert</h1>
|
| 258 |
+
<p style="margin:8px 0 0; color:rgba(255,255,255,0.7); font-size:13px;">{company_name} – HR Dashboard</p>
|
| 259 |
+
</td>
|
| 260 |
+
</tr>
|
| 261 |
+
<tr>
|
| 262 |
+
<td style="padding:36px 40px;">
|
| 263 |
+
<div style="background:#f8f9fc; border-radius:10px; padding:24px; margin-bottom:24px;">
|
| 264 |
+
<h3 style="margin:0 0 16px; font-size:15px; color:#1a1a2e; font-weight:700;">Candidate Details</h3>
|
| 265 |
+
<table width="100%" cellpadding="0" cellspacing="0">
|
| 266 |
+
<tr>
|
| 267 |
+
<td style="padding:8px 0; font-size:14px; color:#888; width:160px;">Full Name</td>
|
| 268 |
+
<td style="padding:8px 0; font-size:14px; color:#333; font-weight:600;">{candidate_name}</td>
|
| 269 |
+
</tr>
|
| 270 |
+
<tr>
|
| 271 |
+
<td style="padding:8px 0; font-size:14px; color:#888;">Email Address</td>
|
| 272 |
+
<td style="padding:8px 0; font-size:14px;">
|
| 273 |
+
<a href="mailto:{candidate_email}" style="color:#4F46E5; text-decoration:none;">{candidate_email}</a>
|
| 274 |
+
</td>
|
| 275 |
+
</tr>
|
| 276 |
+
<tr>
|
| 277 |
+
<td style="padding:8px 0; font-size:14px; color:#888;">Applied For</td>
|
| 278 |
+
<td style="padding:8px 0; font-size:14px; color:#333; font-weight:600;">{job_title}</td>
|
| 279 |
+
</tr>
|
| 280 |
+
<tr>
|
| 281 |
+
<td style="padding:8px 0; font-size:14px; color:#888;">Applied At</td>
|
| 282 |
+
<td style="padding:8px 0; font-size:14px; color:#333;">{applied_at}</td>
|
| 283 |
+
</tr>
|
| 284 |
+
</table>
|
| 285 |
+
</div>
|
| 286 |
+
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
|
| 287 |
+
<tr>
|
| 288 |
+
<td width="48%" style="vertical-align:top; padding-right:8px;">
|
| 289 |
+
<div style="background:#f8f9fc; border-radius:10px; padding:20px; text-align:center;">
|
| 290 |
+
<p style="margin:0 0 6px; font-size:12px; color:#888; text-transform:uppercase; font-weight:600;">ATS Score</p>
|
| 291 |
+
<p style="margin:0; font-size:38px; font-weight:800; color:{score_color};">{int(ats_score)}<span style="font-size:16px; font-weight:400; color:#999;">/100</span></p>
|
| 292 |
+
</div>
|
| 293 |
+
</td>
|
| 294 |
+
<td width="4%"></td>
|
| 295 |
+
<td width="48%" style="vertical-align:top; padding-left:8px;">
|
| 296 |
+
<div style="background:{status_bg}; border:1px solid {status_border}; border-radius:10px; padding:20px; text-align:center;">
|
| 297 |
+
<p style="margin:0 0 6px; font-size:12px; color:#888; text-transform:uppercase; font-weight:600;">Decision</p>
|
| 298 |
+
<p style="margin:0; font-size:22px; font-weight:800; color:{score_color};">{status_label}</p>
|
| 299 |
+
</div>
|
| 300 |
+
</td>
|
| 301 |
+
</tr>
|
| 302 |
+
</table>
|
| 303 |
+
<p style="font-size:14px; color:#888; line-height:1.6; margin:0;">
|
| 304 |
+
The candidate has been automatically notified. Log in to your HR dashboard to view the full AI analysis report.
|
| 305 |
+
</p>
|
| 306 |
+
</td>
|
| 307 |
+
</tr>
|
| 308 |
+
<tr>
|
| 309 |
+
<td style="background:#f8f9fc; padding:20px 40px; text-align:center; border-top:1px solid #eee;">
|
| 310 |
+
<p style="margin:0; font-size:12px; color:#aaa;">This notification was generated automatically by the {company_name} AI Recruitment System.</p>
|
| 311 |
+
</td>
|
| 312 |
+
</tr>
|
| 313 |
+
</table>
|
| 314 |
+
</td>
|
| 315 |
+
</tr>
|
| 316 |
+
</table>
|
| 317 |
+
</body>
|
| 318 |
+
</html>"""
|
| 319 |
+
|
| 320 |
+
return _send_email(hr_email, subject, html_body)
|
main.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from fastapi import FastAPI
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from routes.jobs import router as jobs_router
|
| 5 |
+
from routes.applications import router as applications_router
|
| 6 |
+
from routes.hr import router as hr_router
|
| 7 |
+
|
| 8 |
+
# ---------------------------------------------------------------------------
|
| 9 |
+
# Logging configuration
|
| 10 |
+
# ---------------------------------------------------------------------------
|
| 11 |
+
logging.basicConfig(
|
| 12 |
+
level=logging.INFO,
|
| 13 |
+
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
| 14 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# FastAPI application
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
app = FastAPI(
|
| 23 |
+
title="AI Recruitment System",
|
| 24 |
+
description="Automated resume screening and candidate evaluation powered by AI.",
|
| 25 |
+
version="1.0.0",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# CORS middleware
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
import os
|
| 32 |
+
ALLOWED_ORIGINS = [
|
| 33 |
+
"http://localhost:3000",
|
| 34 |
+
os.getenv("FRONTEND_URL", ""),
|
| 35 |
+
]
|
| 36 |
+
ALLOWED_ORIGINS = [o for o in ALLOWED_ORIGINS if o]
|
| 37 |
+
|
| 38 |
+
app.add_middleware(
|
| 39 |
+
CORSMiddleware,
|
| 40 |
+
allow_origins=ALLOWED_ORIGINS,
|
| 41 |
+
allow_origin_regex=r"https://.*\.vercel\.app",
|
| 42 |
+
allow_credentials=True,
|
| 43 |
+
allow_methods=["*"],
|
| 44 |
+
allow_headers=["*"],
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
# Routers
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
app.include_router(jobs_router, prefix="/api/jobs", tags=["jobs"])
|
| 51 |
+
app.include_router(applications_router, prefix="/api/applications", tags=["applications"])
|
| 52 |
+
app.include_router(hr_router, prefix="/api/hr", tags=["hr"])
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
# Health check
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
@app.get("/", tags=["health"])
|
| 59 |
+
def root():
|
| 60 |
+
return {"message": "AI Recruitment System API", "status": "running", "version": "1.0.0"}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@app.get("/health", tags=["health"])
|
| 64 |
+
def health_check():
|
| 65 |
+
return {"status": "healthy"}
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn==0.30.6
|
| 3 |
+
python-dotenv==1.0.1
|
| 4 |
+
supabase==2.9.0
|
| 5 |
+
openai==1.51.0
|
| 6 |
+
openai-agents==0.0.15
|
| 7 |
+
pdfplumber==0.11.4
|
| 8 |
+
python-multipart==0.0.12
|
| 9 |
+
httpx==0.27.2
|
routes/__init__.py
ADDED
|
File without changes
|
routes/applications.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
import logging
|
| 3 |
+
import asyncio
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 11 |
+
|
| 12 |
+
from supabase_client import supabase
|
| 13 |
+
from email_service import (
|
| 14 |
+
send_selection_email,
|
| 15 |
+
send_rejection_email,
|
| 16 |
+
send_hr_notification_email,
|
| 17 |
+
)
|
| 18 |
+
from agent.resume_agent import analyze_resume
|
| 19 |
+
|
| 20 |
+
router = APIRouter()
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
RESUME_BUCKET = "resumes"
|
| 24 |
+
MAX_FILE_SIZE = 5 * 1024 * 1024
|
| 25 |
+
MIN_TEXT_LENGTH = 50
|
| 26 |
+
AI_TIMEOUT_SECONDS = 60
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _utcnow_str() -> str:
|
| 30 |
+
return datetime.now(timezone.utc).isoformat()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.post("/submit")
|
| 34 |
+
async def submit_application(
|
| 35 |
+
job_id: str = Form(...),
|
| 36 |
+
candidate_name: str = Form(...),
|
| 37 |
+
candidate_email: str = Form(...),
|
| 38 |
+
resume: UploadFile = File(...)
|
| 39 |
+
):
|
| 40 |
+
candidate_name = candidate_name.strip()
|
| 41 |
+
candidate_email = candidate_email.strip().lower()
|
| 42 |
+
|
| 43 |
+
if not candidate_name:
|
| 44 |
+
raise HTTPException(status_code=400, detail="Candidate name is required.")
|
| 45 |
+
if not candidate_email or "@" not in candidate_email:
|
| 46 |
+
raise HTTPException(status_code=400, detail="Valid email address is required.")
|
| 47 |
+
|
| 48 |
+
content_type = resume.content_type or ""
|
| 49 |
+
filename = resume.filename or ""
|
| 50 |
+
if content_type not in ("application/pdf",) and not filename.lower().endswith(".pdf"):
|
| 51 |
+
raise HTTPException(status_code=400, detail="Only PDF files are accepted.")
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
pdf_bytes = await resume.read()
|
| 55 |
+
except Exception as exc:
|
| 56 |
+
logger.error("Failed to read uploaded file: %s", exc)
|
| 57 |
+
raise HTTPException(status_code=400, detail="Could not read the uploaded file.")
|
| 58 |
+
|
| 59 |
+
if not pdf_bytes:
|
| 60 |
+
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
|
| 61 |
+
|
| 62 |
+
if len(pdf_bytes) > MAX_FILE_SIZE:
|
| 63 |
+
raise HTTPException(status_code=400, detail="File size exceeds 5 MB limit.")
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
job_response = supabase.table("jobs").select("*").eq("id", job_id).execute()
|
| 67 |
+
if not job_response.data:
|
| 68 |
+
raise HTTPException(status_code=404, detail="Job posting not found or link is invalid.")
|
| 69 |
+
job = job_response.data[0]
|
| 70 |
+
except HTTPException:
|
| 71 |
+
raise
|
| 72 |
+
except Exception as exc:
|
| 73 |
+
logger.error("Failed to fetch job %s: %s", job_id, exc)
|
| 74 |
+
raise HTTPException(status_code=500, detail="Could not verify job posting.")
|
| 75 |
+
|
| 76 |
+
if not job.get("is_active", True):
|
| 77 |
+
raise HTTPException(status_code=410, detail="This job posting is no longer accepting applications.")
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
dup = (
|
| 81 |
+
supabase.table("applications")
|
| 82 |
+
.select("id")
|
| 83 |
+
.eq("job_id", job_id)
|
| 84 |
+
.eq("candidate_email", candidate_email)
|
| 85 |
+
.execute()
|
| 86 |
+
)
|
| 87 |
+
if dup.data:
|
| 88 |
+
raise HTTPException(
|
| 89 |
+
status_code=409,
|
| 90 |
+
detail="You have already applied for this position. Check your email for the result."
|
| 91 |
+
)
|
| 92 |
+
except HTTPException:
|
| 93 |
+
raise
|
| 94 |
+
except Exception as exc:
|
| 95 |
+
logger.warning("Duplicate check failed (continuing): %s", exc)
|
| 96 |
+
|
| 97 |
+
job_title = job.get("title", "Unknown Position")
|
| 98 |
+
company_name = job.get("company_name", "Our Company")
|
| 99 |
+
required_skills = job.get("required_skills", [])
|
| 100 |
+
experience_years = job.get("experience_years", 0)
|
| 101 |
+
job_description = job.get("description", "")
|
| 102 |
+
hr_email = job.get("hr_email")
|
| 103 |
+
|
| 104 |
+
application_id = str(uuid.uuid4())
|
| 105 |
+
applied_at = _utcnow_str()
|
| 106 |
+
storage_path = f"{job_id}/{application_id}.pdf"
|
| 107 |
+
resume_url = None
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
supabase.storage.from_(RESUME_BUCKET).upload(
|
| 111 |
+
path=storage_path,
|
| 112 |
+
file=pdf_bytes,
|
| 113 |
+
file_options={"content-type": "application/pdf"}
|
| 114 |
+
)
|
| 115 |
+
resume_url = supabase.storage.from_(RESUME_BUCKET).get_public_url(storage_path)
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
logger.warning("Resume storage upload failed (continuing): %s", exc)
|
| 118 |
+
|
| 119 |
+
try:
|
| 120 |
+
insert_resp = supabase.table("applications").insert({
|
| 121 |
+
"id": application_id,
|
| 122 |
+
"job_id": job_id,
|
| 123 |
+
"candidate_name": candidate_name,
|
| 124 |
+
"candidate_email": candidate_email,
|
| 125 |
+
"resume_url": resume_url,
|
| 126 |
+
"status": "pending",
|
| 127 |
+
"ats_score": None,
|
| 128 |
+
"missing_skills": [],
|
| 129 |
+
"ai_feedback": None,
|
| 130 |
+
"applied_at": applied_at,
|
| 131 |
+
}).execute()
|
| 132 |
+
if not insert_resp.data:
|
| 133 |
+
raise RuntimeError("Insert returned no data")
|
| 134 |
+
except Exception as exc:
|
| 135 |
+
logger.error("Failed to insert application: %s", exc)
|
| 136 |
+
raise HTTPException(status_code=500, detail="Failed to save application. Please try again.")
|
| 137 |
+
|
| 138 |
+
final_status = "pending"
|
| 139 |
+
ats_score = 0
|
| 140 |
+
missing_skills = []
|
| 141 |
+
ai_feedback = None
|
| 142 |
+
skill_recs = []
|
| 143 |
+
ai_result = None
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
ai_result = await asyncio.wait_for(
|
| 147 |
+
analyze_resume(
|
| 148 |
+
pdf_bytes=pdf_bytes,
|
| 149 |
+
job_title=job_title,
|
| 150 |
+
job_description=job_description,
|
| 151 |
+
required_skills=required_skills,
|
| 152 |
+
experience_years=experience_years,
|
| 153 |
+
company_name=company_name,
|
| 154 |
+
),
|
| 155 |
+
timeout=AI_TIMEOUT_SECONDS,
|
| 156 |
+
)
|
| 157 |
+
final_status = ai_result.get("status", "pending")
|
| 158 |
+
ats_score = int(ai_result.get("ats_score", 0))
|
| 159 |
+
missing_skills = ai_result.get("missing_skills", [])
|
| 160 |
+
ai_feedback = ai_result.get("ai_feedback", "")
|
| 161 |
+
skill_recs = ai_result.get("skill_recommendations", [])
|
| 162 |
+
|
| 163 |
+
except asyncio.TimeoutError:
|
| 164 |
+
logger.error("AI analysis timed out for application %s", application_id)
|
| 165 |
+
ai_feedback = "Analysis timed out. HR will review manually."
|
| 166 |
+
except ValueError as exc:
|
| 167 |
+
logger.error("PDF/AI error for application %s: %s", application_id, exc)
|
| 168 |
+
ai_feedback = str(exc)
|
| 169 |
+
except Exception as exc:
|
| 170 |
+
logger.error("AI analysis failed for application %s: %s", application_id, exc)
|
| 171 |
+
|
| 172 |
+
try:
|
| 173 |
+
supabase.table("applications").update({
|
| 174 |
+
"status": final_status,
|
| 175 |
+
"ats_score": ats_score,
|
| 176 |
+
"missing_skills": missing_skills,
|
| 177 |
+
"ai_feedback": ai_feedback,
|
| 178 |
+
"skill_recommendations": skill_recs,
|
| 179 |
+
"updated_at": _utcnow_str(),
|
| 180 |
+
}).eq("id", application_id).execute()
|
| 181 |
+
except Exception as exc:
|
| 182 |
+
logger.error("Failed to update application %s: %s", application_id, exc)
|
| 183 |
+
|
| 184 |
+
if ai_result is not None and final_status in ("selected", "rejected"):
|
| 185 |
+
try:
|
| 186 |
+
if final_status == "selected":
|
| 187 |
+
send_selection_email(
|
| 188 |
+
candidate_name=candidate_name,
|
| 189 |
+
candidate_email=candidate_email,
|
| 190 |
+
job_title=job_title,
|
| 191 |
+
company_name=company_name,
|
| 192 |
+
ats_score=ats_score,
|
| 193 |
+
)
|
| 194 |
+
if hr_email:
|
| 195 |
+
send_hr_notification_email(
|
| 196 |
+
hr_email=hr_email,
|
| 197 |
+
candidate_name=candidate_name,
|
| 198 |
+
candidate_email=candidate_email,
|
| 199 |
+
job_title=job_title,
|
| 200 |
+
company_name=company_name,
|
| 201 |
+
ats_score=ats_score,
|
| 202 |
+
applied_at=applied_at,
|
| 203 |
+
)
|
| 204 |
+
else:
|
| 205 |
+
send_rejection_email(
|
| 206 |
+
candidate_name=candidate_name,
|
| 207 |
+
candidate_email=candidate_email,
|
| 208 |
+
job_title=job_title,
|
| 209 |
+
company_name=company_name,
|
| 210 |
+
ats_score=ats_score,
|
| 211 |
+
missing_skills_with_recommendations=skill_recs,
|
| 212 |
+
)
|
| 213 |
+
except Exception as exc:
|
| 214 |
+
logger.error("Email failed for application %s: %s", application_id, exc)
|
| 215 |
+
|
| 216 |
+
logger.info("Application %s processed: status=%s ats=%s", application_id, final_status, ats_score)
|
| 217 |
+
|
| 218 |
+
return {
|
| 219 |
+
"success": True,
|
| 220 |
+
"application_id": application_id,
|
| 221 |
+
"status": final_status,
|
| 222 |
+
"ats_score": ats_score,
|
| 223 |
+
"candidate_name": candidate_name,
|
| 224 |
+
"missing_skills": missing_skills,
|
| 225 |
+
"ai_feedback": ai_feedback,
|
| 226 |
+
"skill_recommendations": skill_recs,
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@router.get("/{job_id}")
|
| 231 |
+
async def get_applications(job_id: str):
|
| 232 |
+
try:
|
| 233 |
+
response = (
|
| 234 |
+
supabase.table("applications")
|
| 235 |
+
.select("*")
|
| 236 |
+
.eq("job_id", job_id)
|
| 237 |
+
.order("applied_at", desc=True)
|
| 238 |
+
.execute()
|
| 239 |
+
)
|
| 240 |
+
except Exception as exc:
|
| 241 |
+
logger.error("Failed to fetch applications for job %s: %s", job_id, exc)
|
| 242 |
+
raise HTTPException(status_code=500, detail="Failed to retrieve applications.")
|
| 243 |
+
|
| 244 |
+
return {
|
| 245 |
+
"success": True,
|
| 246 |
+
"job_id": job_id,
|
| 247 |
+
"applications": response.data or [],
|
| 248 |
+
"total": len(response.data or []),
|
| 249 |
+
}
|
routes/hr.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from fastapi import APIRouter, HTTPException
|
| 3 |
+
from pydantic import BaseModel, EmailStr
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 8 |
+
|
| 9 |
+
from supabase_client import supabase
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class HRRegisterRequest(BaseModel):
|
| 16 |
+
id: str
|
| 17 |
+
name: str
|
| 18 |
+
company_name: str
|
| 19 |
+
email: EmailStr
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.post("/register")
|
| 23 |
+
async def register_hr(payload: HRRegisterRequest):
|
| 24 |
+
try:
|
| 25 |
+
response = supabase.table("hr_users").upsert({
|
| 26 |
+
"id": payload.id,
|
| 27 |
+
"name": payload.name,
|
| 28 |
+
"company_name": payload.company_name,
|
| 29 |
+
"email": payload.email,
|
| 30 |
+
}).execute()
|
| 31 |
+
except Exception as exc:
|
| 32 |
+
logger.error("Failed to insert HR user: %s", exc)
|
| 33 |
+
raise HTTPException(status_code=500, detail="Failed to save HR profile.")
|
| 34 |
+
|
| 35 |
+
logger.info("HR user registered: id=%s email=%s", payload.id, payload.email)
|
| 36 |
+
return {"success": True, "user_id": payload.id}
|
routes/jobs.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
import logging
|
| 3 |
+
from fastapi import APIRouter, HTTPException
|
| 4 |
+
from pydantic import BaseModel, EmailStr
|
| 5 |
+
from typing import List
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 10 |
+
|
| 11 |
+
from supabase_client import supabase
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class CreateJobRequest(BaseModel):
|
| 18 |
+
title: str
|
| 19 |
+
company_name: str
|
| 20 |
+
description: str
|
| 21 |
+
required_skills: List[str]
|
| 22 |
+
experience_years: int
|
| 23 |
+
hr_id: str
|
| 24 |
+
hr_email: str
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@router.post("/create")
|
| 28 |
+
async def create_job(payload: CreateJobRequest):
|
| 29 |
+
unique_link = str(uuid.uuid4())
|
| 30 |
+
|
| 31 |
+
job_data = {
|
| 32 |
+
"title": payload.title,
|
| 33 |
+
"company_name": payload.company_name,
|
| 34 |
+
"description": payload.description,
|
| 35 |
+
"required_skills": payload.required_skills,
|
| 36 |
+
"experience_years": payload.experience_years,
|
| 37 |
+
"hr_id": payload.hr_id,
|
| 38 |
+
"hr_email": payload.hr_email,
|
| 39 |
+
"unique_link": unique_link,
|
| 40 |
+
"is_active": True,
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
response = supabase.table("jobs").insert(job_data).execute()
|
| 45 |
+
except Exception as exc:
|
| 46 |
+
logger.error("Failed to create job in Supabase: %s", exc)
|
| 47 |
+
raise HTTPException(status_code=500, detail="Failed to create job posting.")
|
| 48 |
+
|
| 49 |
+
if not response.data:
|
| 50 |
+
raise HTTPException(status_code=500, detail="Job creation returned no data.")
|
| 51 |
+
|
| 52 |
+
created_job = response.data[0]
|
| 53 |
+
logger.info("Job created: id=%s title=%s", created_job.get("id"), payload.title)
|
| 54 |
+
return {
|
| 55 |
+
"success": True,
|
| 56 |
+
"job": created_job,
|
| 57 |
+
"unique_link": unique_link,
|
| 58 |
+
"application_url": f"/apply/{unique_link}"
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@router.get("/{job_id}")
|
| 63 |
+
async def get_job(job_id: str):
|
| 64 |
+
try:
|
| 65 |
+
response = supabase.table("jobs").select("*").eq("unique_link", job_id).execute()
|
| 66 |
+
if not response.data:
|
| 67 |
+
response = supabase.table("jobs").select("*").eq("id", job_id).execute()
|
| 68 |
+
except Exception as exc:
|
| 69 |
+
logger.error("Failed to fetch job %s: %s", job_id, exc)
|
| 70 |
+
raise HTTPException(status_code=500, detail="Failed to retrieve job details.")
|
| 71 |
+
|
| 72 |
+
if not response.data:
|
| 73 |
+
raise HTTPException(status_code=404, detail="Job not found.")
|
| 74 |
+
|
| 75 |
+
return {"success": True, "job": response.data[0]}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@router.get("/hr/{hr_id}")
|
| 79 |
+
async def get_hr_jobs(hr_id: str):
|
| 80 |
+
try:
|
| 81 |
+
jobs_response = (
|
| 82 |
+
supabase.table("jobs")
|
| 83 |
+
.select("*")
|
| 84 |
+
.eq("hr_id", hr_id)
|
| 85 |
+
.order("created_at", desc=True)
|
| 86 |
+
.execute()
|
| 87 |
+
)
|
| 88 |
+
except Exception as exc:
|
| 89 |
+
logger.error("Failed to fetch jobs for hr_id=%s: %s", hr_id, exc)
|
| 90 |
+
raise HTTPException(status_code=500, detail="Failed to retrieve jobs.")
|
| 91 |
+
|
| 92 |
+
jobs = jobs_response.data or []
|
| 93 |
+
|
| 94 |
+
enriched_jobs = []
|
| 95 |
+
for job in jobs:
|
| 96 |
+
job_id = job.get("id")
|
| 97 |
+
try:
|
| 98 |
+
count_response = (
|
| 99 |
+
supabase.table("applications")
|
| 100 |
+
.select("id", count="exact")
|
| 101 |
+
.eq("job_id", job_id)
|
| 102 |
+
.execute()
|
| 103 |
+
)
|
| 104 |
+
applicant_count = count_response.count if count_response.count is not None else 0
|
| 105 |
+
except Exception as exc:
|
| 106 |
+
logger.warning("Could not fetch applicant count for job %s: %s", job_id, exc)
|
| 107 |
+
applicant_count = 0
|
| 108 |
+
|
| 109 |
+
enriched_jobs.append({**job, "applicant_count": applicant_count})
|
| 110 |
+
|
| 111 |
+
return {"success": True, "jobs": enriched_jobs, "total": len(enriched_jobs)}
|
supabase_client.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from supabase import create_client, Client
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
| 8 |
+
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
| 9 |
+
|
| 10 |
+
if not SUPABASE_URL or not SUPABASE_SERVICE_KEY:
|
| 11 |
+
raise ValueError("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set in environment variables")
|
| 12 |
+
|
| 13 |
+
supabase: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
|