first commit

This commit is contained in:
Anurag Jangid 2026-10-08 02:50:35 +05:30
commit 7a72438fd1
74 changed files with 7548 additions and 0 deletions

31
backend/.env Normal file
View file

@ -0,0 +1,31 @@
# ── Google OAuth ──────────────────────────────────────────────
# Get these from https://console.cloud.google.com/
# Authorized redirect URI must include: http://localhost:8000/api/auth/google/callback
GOOGLE_CLIENT_ID=49118838430-vf3ttj24hour4rcj48boo7n58dh7l8jo.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
# ── JWT ───────────────────────────────────────────────────────
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
JWT_SECRET=supersecretchangeme123456789abcdefghijklmnop
JWT_ALGORITHM=HS256
JWT_EXPIRE_HOURS=168
# ── MongoDB ───────────────────────────────────────────────────
MONGODB_URI=mongodb://localhost:27017
MONGODB_DB=hireai
# ── Gemini AI ─────────────────────────────────────────────────
GEMINI_API_KEY=AIzaSyDQkd0AlWlCU2mFyq99aLTXj83qBncIUgQ
# ── App URLs ──────────────────────────────────────────────────
FRONTEND_URL=http://localhost:5173
BACKEND_URL=http://localhost:8000
# ── Email (SMTP) ──────────────────────────────────────────────
MAIL_USERNAME=your-gmail@gmail.com
MAIL_PASSWORD=your-app-password
MAIL_FROM=your-gmail@gmail.com
MAIL_SERVER=smtp.gmail.com
MAIL_PORT=587
MAIL_TLS=True
MAIL_SSL=False

Binary file not shown.

1
backend/app/__init__.py Normal file
View file

@ -0,0 +1 @@
# HireAI app package

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

40
backend/app/config.py Normal file
View file

@ -0,0 +1,40 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
class Settings(BaseSettings):
# Google OAuth
google_client_id: str = ""
google_client_secret: str = ""
# JWT
jwt_secret: str = "supersecretchangeme"
jwt_algorithm: str = "HS256"
jwt_expire_hours: int = 168 # 7 days
# MongoDB
mongodb_uri: str = "mongodb://localhost:27017"
mongodb_db: str = "hireai"
# Gemini
gemini_api_key: str = ""
# URLs
frontend_url: str = "http://localhost:5173"
backend_url: str = "http://localhost:8000"
# Email
mail_username: str = ""
mail_password: str = ""
mail_from: str = ""
mail_server: str = "smtp.gmail.com"
mail_port: int = 587
mail_tls: bool = True
mail_ssl: bool = False
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@lru_cache
def get_settings() -> Settings:
return Settings()

39
backend/app/database.py Normal file
View file

@ -0,0 +1,39 @@
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorGridFSBucket
from pymongo.server_api import ServerApi
from app.config import get_settings
settings = get_settings()
client: AsyncIOMotorClient = None
db = None
gridfs_bucket: AsyncIOMotorGridFSBucket = None
async def connect_db():
global client, db, gridfs_bucket
client = AsyncIOMotorClient(
settings.mongodb_uri,
server_api=ServerApi("1"),
)
db = client[settings.mongodb_db]
gridfs_bucket = AsyncIOMotorGridFSBucket(db, bucket_name="resumes")
# Ping to verify connection
await client.admin.command("ping")
print(f"[OK] Connected to MongoDB: {settings.mongodb_db}")
async def close_db():
global client
if client:
client.close()
print("[CLOSED] MongoDB connection closed")
def get_db():
return db
def get_gridfs():
return gridfs_bucket

View file

@ -0,0 +1,58 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from app.config import get_settings
from app.database import get_db
settings = get_settings()
bearer_scheme = HTTPBearer(auto_error=False)
def create_access_token(data: dict) -> str:
payload = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_expire_hours)
payload.update({"exp": expire, "iat": datetime.now(timezone.utc)})
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> Optional[dict]:
try:
return jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
except JWTError:
return None
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
db=Depends(get_db),
):
"""FastAPI dependency: validates JWT and returns the current user doc."""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_token(credentials.credentials)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user = await db["users"].find_one({"google_id": payload.get("sub")})
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
return user

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,13 @@
from pydantic import BaseModel
from typing import List
from datetime import datetime
class PronunciationItem(BaseModel):
word: str
phonetic: str
syllables: List[str]
definition: str
class PronunciationDB(PronunciationItem):
id: str
created_at: datetime

View file

@ -0,0 +1,22 @@
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
class QuestionItem(BaseModel):
question: str
expected_answer: str
hint: str
difficulty: Optional[str] = "Medium"
category: Optional[str] = "Technical"
class QuestionSetIn(BaseModel):
resume_id: str
requirement_id: str
class QuestionSetOut(BaseModel):
id: str
user_id: str
resume_id: str
requirement_id: str
questions: List[QuestionItem]
created_at: datetime

View file

@ -0,0 +1,18 @@
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
class RequirementsIn(BaseModel):
role_name: str
experience_years: int = 0
mandatory_skills: List[str] = []
nice_to_have_skills: List[str] = []
job_description: Optional[str] = None
class RequirementsOut(RequirementsIn):
id: str
user_id: str
created_at: datetime
updated_at: datetime

View file

@ -0,0 +1,31 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from bson import ObjectId
class ParsedResume(BaseModel):
name: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
linkedin: Optional[str] = None
github: Optional[str] = None
website: Optional[str] = None
objective: Optional[str] = None
skills: List[str] = []
languages: List[str] = []
interests: List[str] = []
projects: List[dict] = []
education: List[dict] = []
experience: List[dict] = []
raw_text: str = ""
class ResumeOut(BaseModel):
id: str
filename: str
file_type: str
uploaded_by: str
parsed: ParsedResume
uploaded_at: str
gridfs_id: Optional[str] = None

View file

@ -0,0 +1,22 @@
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from datetime import datetime
class ScheduleIn(BaseModel):
resume_id: str
candidate_email: EmailStr
candidate_name: str
interview_date: str # ISO string or YYYY-MM-DD
interview_time: str # HH:MM AM/PM
interview_type: str = "Technical" # Technical, HR, etc.
meeting_link: Optional[str] = None
notes: Optional[str] = None
class ScheduleOut(ScheduleIn):
id: str
user_id: str
status: str = "Scheduled" # Scheduled, Completed, Cancelled
created_at: datetime
updated_at: datetime

View file

@ -0,0 +1,19 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
class UserInDB(BaseModel):
google_id: str
email: EmailStr
name: str
picture: Optional[str] = None
created_at: datetime = None
last_login: datetime = None
class UserOut(BaseModel):
google_id: str
email: str
name: str
picture: Optional[str] = None

View file

@ -0,0 +1,3 @@
from app.routers import auth, resume, requirements, schedule, questions, pronunciation
__all__ = ["auth", "resume", "requirements", "schedule", "questions", "pronunciation"]

Binary file not shown.

Binary file not shown.

130
backend/app/routers/auth.py Normal file
View file

@ -0,0 +1,130 @@
"""
Google OAuth 2.0 flow:
1. GET /api/auth/google/login → redirect to Google consent screen
2. GET /api/auth/google/callback → exchange code for tokens → get user info
→ upsert user in MongoDB → issue JWT
→ redirect to frontend with token
3. GET /api/auth/me → return current user (protected)
4. POST /api/auth/logout → client-side only (stateless JWT), just 200
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse, JSONResponse
import httpx
from datetime import datetime, timezone
from app.config import get_settings
from app.database import get_db
from app.dependencies import create_access_token, get_current_user
from app.models.user import UserOut
router = APIRouter(prefix="/api/auth", tags=["auth"])
settings = get_settings()
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
SCOPES = "openid email profile"
# ── Step 1: Redirect to Google ─────────────────────────────────
@router.get("/google/login")
async def google_login():
redirect_uri = f"{settings.backend_url}/api/auth/google/callback"
params = (
f"?client_id={settings.google_client_id}"
f"&redirect_uri={redirect_uri}"
f"&response_type=code"
f"&scope={SCOPES}"
f"&access_type=offline"
f"&prompt=select_account"
)
return RedirectResponse(url=GOOGLE_AUTH_URL + params)
# ── Step 2: Callback from Google ───────────────────────────────
@router.get("/google/callback")
async def google_callback(code: str, db=Depends(get_db)):
redirect_uri = f"{settings.backend_url}/api/auth/google/callback"
# Exchange authorization code for tokens
async with httpx.AsyncClient() as client:
token_response = await client.post(
GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if token_response.status_code != 200:
error_url = f"{settings.frontend_url}/login?error=google_token_failed"
return RedirectResponse(url=error_url)
tokens = token_response.json()
access_token = tokens.get("access_token")
# Fetch user profile from Google
async with httpx.AsyncClient() as client:
userinfo_response = await client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
if userinfo_response.status_code != 200:
error_url = f"{settings.frontend_url}/login?error=google_userinfo_failed"
return RedirectResponse(url=error_url)
google_user = userinfo_response.json()
google_id = google_user.get("sub")
email = google_user.get("email", "")
name = google_user.get("name", "")
picture = google_user.get("picture", "")
# Upsert user in MongoDB
now = datetime.now(timezone.utc)
await db["users"].update_one(
{"google_id": google_id},
{
"$set": {
"email": email,
"name": name,
"picture": picture,
"last_login": now,
},
"$setOnInsert": {
"google_id": google_id,
"created_at": now,
},
},
upsert=True,
)
# Issue JWT
jwt_token = create_access_token(
data={"sub": google_id, "email": email, "name": name, "picture": picture}
)
# Redirect to frontend with token
frontend_redirect = f"{settings.frontend_url}/auth/callback?token={jwt_token}"
return RedirectResponse(url=frontend_redirect)
# ── Get current user (protected) ──────────────────────────────
@router.get("/me", response_model=UserOut)
async def get_me(current_user: dict = Depends(get_current_user)):
return UserOut(
google_id=current_user["google_id"],
email=current_user["email"],
name=current_user["name"],
picture=current_user.get("picture"),
)
# ── Logout (stateless — client drops the JWT) ─────────────────
@router.post("/logout")
async def logout():
return JSONResponse({"message": "Logged out successfully"})

View file

@ -0,0 +1,79 @@
from fastapi import APIRouter, Depends, HTTPException
from datetime import datetime, timezone
from typing import List
import random
from app.database import get_db
from app.models.pronunciation import PronunciationDB
from app.services.gemini_service import get_word_pronunciation
router = APIRouter(prefix="/api/pronunciation", tags=["pronunciation"])
TECH_TERMS = [
"Kubernetes", "PostgreSQL", "Nginx", "Polymorphism", "Encapsulation",
"Asynchronous", "Concurrency", "Django", "Vue", "TypeScript",
"GraphQL", "Docker", "Microservices", "Idempotent", "Heuristic",
"Tuple", "Boolean", "JSON", "YAML", "Ubuntu",
"Vite", "Pinia", "Axios", "FastAPI", "Uvicorn",
"OAuth", "JWT", "PyMuPDF", "GridFS", "MongoDB",
"Motor", "Asynchronous", "Middleware", "Interceptor", "Composition",
"Component", "Reactive", "Computed", "State", "Mutation",
"Action", "Getter", "Router", "Guard", "Plugin"
]
@router.get("/tech-terms", response_model=List[str])
async def get_tech_terms():
"""Returns a list of 50 common tech terms to prepopulate the UI."""
return TECH_TERMS
@router.get("/word-of-the-day")
async def get_word_of_the_day(db=Depends(get_db)):
"""Returns a random word from the DB, or a default one if DB is empty."""
count = await db["pronunciations"].count_documents({})
if count == 0:
return {"word": "Polymorphism", "phonetic": "/ˌpɒlɪˈmɔːfɪzəm/", "definition": "The provision of a single interface to entities of different types."}
# Pick a random document
import random
random_skip = random.randint(0, count - 1)
doc = await db["pronunciations"].find().skip(random_skip).limit(1).to_list(length=1)
if doc:
return {
"word": doc[0]["word"],
"phonetic": doc[0]["phonetic"],
"definition": doc[0]["definition"]
}
return {"word": "Polymorphism", "phonetic": "/ˌpɒlɪˈmɔːfɪzəm/", "definition": "The provision of a single interface to entities of different types."}
@router.get("/{word}", response_model=PronunciationDB)
async def get_pronunciation(word: str, db=Depends(get_db)):
"""Fetches pronunciation info from MongoDB or Gemini."""
word_lower = word.lower().strip()
# 1. Check DB Cache
existing = await db["pronunciations"].find_one({"word_lower": word_lower})
if existing:
existing["id"] = str(existing.pop("_id"))
return existing
# 2. Call Gemini
try:
data = await get_word_pronunciation(word)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# 3. Store in DB
now = datetime.now(timezone.utc)
doc = {
"word": data["word"],
"word_lower": word_lower,
"phonetic": data["phonetic"],
"syllables": data["syllables"],
"definition": data["definition"],
"created_at": now
}
result = await db["pronunciations"].insert_one(doc)
doc["id"] = str(result.inserted_id)
return doc

View file

@ -0,0 +1,84 @@
from fastapi import APIRouter, Depends, HTTPException, status
from bson import ObjectId
from datetime import datetime, timezone
from typing import List
from app.database import get_db
from app.dependencies import get_current_user
from app.models.question import QuestionSetIn, QuestionSetOut, QuestionItem
from app.services.gemini_service import generate_interview_questions
router = APIRouter(prefix="/api/questions", tags=["questions"])
@router.post("/generate", response_model=QuestionSetOut, status_code=status.HTTP_201_CREATED)
async def generate_questions(
req: QuestionSetIn,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
resume_oid = ObjectId(req.resume_id)
req_oid = ObjectId(req.requirement_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID format")
# Fetch resume
resume = await db["resumes"].find_one({"_id": resume_oid, "uploaded_by": current_user["google_id"]})
if not resume:
raise HTTPException(status_code=404, detail="Resume not found")
# Fetch requirements
requirement = await db["requirements"].find_one({"_id": req_oid, "user_id": current_user["google_id"]})
if not requirement:
raise HTTPException(status_code=404, detail="Requirement not found")
# Generate questions via Gemini
try:
# Pass resume text and requirements to Gemini
generated_data = await generate_interview_questions(
resume_text=resume.get("parsed", {}).get("raw_text", ""),
role_name=requirement.get("role_name", ""),
skills=requirement.get("mandatory_skills", []) + requirement.get("nice_to_have_skills", []),
experience=requirement.get("experience_years", 0),
projects=resume.get("parsed", {}).get("projects", [])
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Save to MongoDB
now = datetime.now(timezone.utc)
doc = {
"user_id": current_user["google_id"],
"resume_id": req.resume_id,
"requirement_id": req.requirement_id,
"questions": generated_data,
"created_at": now
}
# Upsert logic to overwrite if same pair exists
result = await db["questions"].find_one_and_update(
{
"user_id": current_user["google_id"],
"resume_id": req.resume_id,
"requirement_id": req.requirement_id
},
{"$set": doc},
upsert=True,
return_document=True
)
result["id"] = str(result.pop("_id"))
return result
@router.get("/{resume_id}", response_model=List[QuestionSetOut])
async def get_questions_for_resume(
resume_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
cursor = db["questions"].find({"user_id": current_user["google_id"], "resume_id": resume_id}).sort("created_at", -1)
results = []
async for doc in cursor:
doc["id"] = str(doc.pop("_id"))
results.append(doc)
return results

View file

@ -0,0 +1,98 @@
from fastapi import APIRouter, Depends, HTTPException, status
from bson import ObjectId
from datetime import datetime, timezone
from typing import List
from app.database import get_db
from app.dependencies import get_current_user
from app.models.requirements import RequirementsIn, RequirementsOut
router = APIRouter(prefix="/api/requirements", tags=["requirements"])
@router.post("/", response_model=RequirementsOut, status_code=status.HTTP_201_CREATED)
async def create_requirements(
req: RequirementsIn,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
now = datetime.now(timezone.utc)
doc = req.model_dump()
doc["user_id"] = current_user["google_id"]
doc["created_at"] = now
doc["updated_at"] = now
result = await db["requirements"].insert_one(doc)
doc["id"] = str(result.inserted_id)
return doc
@router.get("/", response_model=List[RequirementsOut])
async def list_requirements(
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
cursor = db["requirements"].find({"user_id": current_user["google_id"]}).sort("updated_at", -1)
reqs = []
async for doc in cursor:
doc["id"] = str(doc.pop("_id"))
reqs.append(doc)
return reqs
@router.get("/{req_id}", response_model=RequirementsOut)
async def get_requirement(
req_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(req_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
doc = await db["requirements"].find_one({"_id": oid, "user_id": current_user["google_id"]})
if not doc:
raise HTTPException(status_code=404, detail="Requirement not found")
doc["id"] = str(doc.pop("_id"))
return doc
@router.put("/{req_id}", response_model=RequirementsOut)
async def update_requirement(
req_id: str,
req: RequirementsIn,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(req_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
update_data = req.model_dump()
update_data["updated_at"] = datetime.now(timezone.utc)
result = await db["requirements"].find_one_and_update(
{"_id": oid, "user_id": current_user["google_id"]},
{"$set": update_data},
return_document=True
)
if not result:
raise HTTPException(status_code=404, detail="Requirement not found")
result["id"] = str(result.pop("_id"))
return result
@router.delete("/{req_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_requirement(
req_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(req_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
result = await db["requirements"].delete_one({"_id": oid, "user_id": current_user["google_id"]})
if result.deleted_count == 0:
raise HTTPException(status_code=404, detail="Requirement not found")

View file

@ -0,0 +1,180 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from fastapi.responses import StreamingResponse
from bson import ObjectId
from datetime import datetime, timezone
import io
from app.database import get_db, get_gridfs
from app.dependencies import get_current_user
from app.services.resume_parser import parse_pdf, parse_docx
router = APIRouter(prefix="/api/resume", tags=["resume"])
ALLOWED_TYPES = {
"application/pdf": "pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/msword": "doc",
}
MAX_SIZE_MB = 10
# ── Upload & Parse ──────────────────────────────────────────────
@router.post("/upload", status_code=status.HTTP_201_CREATED)
async def upload_resume(
file: UploadFile = File(...),
db=Depends(get_db),
gridfs=Depends(get_gridfs),
current_user: dict = Depends(get_current_user),
):
# Validate content type
content_type = file.content_type or ""
if content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=400,
detail="Only PDF and DOCX files are accepted.",
)
file_bytes = await file.read()
# Validate size
if len(file_bytes) > MAX_SIZE_MB * 1024 * 1024:
raise HTTPException(
status_code=400,
detail=f"File too large. Max size is {MAX_SIZE_MB}MB.",
)
# Parse resume
file_type = ALLOWED_TYPES[content_type]
if file_type == "pdf":
parsed = parse_pdf(file_bytes)
else:
parsed = parse_docx(file_bytes)
# Store file in GridFS
gridfs_id = await gridfs.upload_from_stream(
file.filename,
io.BytesIO(file_bytes),
metadata={"content_type": content_type, "uploaded_by": current_user["google_id"]},
)
# Store metadata in MongoDB
doc = {
"filename": file.filename,
"file_type": file_type,
"content_type": content_type,
"uploaded_by": current_user["google_id"],
"gridfs_id": str(gridfs_id),
"parsed": parsed,
"uploaded_at": datetime.now(timezone.utc),
}
result = await db["resumes"].insert_one(doc)
return {
"id": str(result.inserted_id),
"filename": file.filename,
"file_type": file_type,
"parsed": parsed,
"uploaded_at": doc["uploaded_at"].isoformat(),
"message": "Resume uploaded and parsed successfully",
}
# ── List all resumes for current user ──────────────────────────
@router.get("/")
async def list_resumes(
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
cursor = db["resumes"].find(
{"uploaded_by": current_user["google_id"]},
{"parsed.raw_text": 0}, # exclude raw text from list view
).sort("uploaded_at", -1)
resumes = []
async for doc in cursor:
doc["id"] = str(doc.pop("_id"))
doc["uploaded_at"] = doc["uploaded_at"].isoformat() if doc.get("uploaded_at") else ""
resumes.append(doc)
return resumes
# ── Get single resume ──────────────────────────────────────────
@router.get("/{resume_id}")
async def get_resume(
resume_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(resume_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid resume ID")
doc = await db["resumes"].find_one(
{"_id": oid, "uploaded_by": current_user["google_id"]}
)
if not doc:
raise HTTPException(status_code=404, detail="Resume not found")
doc["id"] = str(doc.pop("_id"))
doc["uploaded_at"] = doc["uploaded_at"].isoformat() if doc.get("uploaded_at") else ""
return doc
# ── Download original file ─────────────────────────────────────
@router.get("/{resume_id}/download")
async def download_resume(
resume_id: str,
db=Depends(get_db),
gridfs=Depends(get_gridfs),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(resume_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid resume ID")
doc = await db["resumes"].find_one(
{"_id": oid, "uploaded_by": current_user["google_id"]}
)
if not doc:
raise HTTPException(status_code=404, detail="Resume not found")
gridfs_id = ObjectId(doc["gridfs_id"])
stream = await gridfs.open_download_stream(gridfs_id)
content = await stream.read()
return StreamingResponse(
io.BytesIO(content),
media_type=doc["content_type"],
headers={"Content-Disposition": f'attachment; filename="{doc["filename"]}"'},
)
# ── Delete resume ──────────────────────────────────────────────
@router.delete("/{resume_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_resume(
resume_id: str,
db=Depends(get_db),
gridfs=Depends(get_gridfs),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(resume_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid resume ID")
doc = await db["resumes"].find_one(
{"_id": oid, "uploaded_by": current_user["google_id"]}
)
if not doc:
raise HTTPException(status_code=404, detail="Resume not found")
# Delete from GridFS
try:
await gridfs.delete(ObjectId(doc["gridfs_id"]))
except Exception:
pass # GridFS file may already be gone
await db["resumes"].delete_one({"_id": oid})

View file

@ -0,0 +1,110 @@
from fastapi import APIRouter, Depends, HTTPException, status
from bson import ObjectId
from datetime import datetime, timezone
from typing import List
from app.database import get_db
from app.dependencies import get_current_user
from app.models.schedule import ScheduleIn, ScheduleOut
from app.services.email import send_interview_email
router = APIRouter(prefix="/api/schedule", tags=["schedule"])
@router.post("/", response_model=ScheduleOut, status_code=status.HTTP_201_CREATED)
async def create_schedule(
sched: ScheduleIn,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
now = datetime.now(timezone.utc)
doc = sched.model_dump()
doc["user_id"] = current_user["google_id"]
doc["status"] = "Scheduled"
doc["created_at"] = now
doc["updated_at"] = now
result = await db["schedules"].insert_one(doc)
doc["id"] = str(result.inserted_id)
# Try sending email
email_sent = await send_interview_email(
candidate_email=sched.candidate_email,
candidate_name=sched.candidate_name,
date=sched.interview_date,
time=sched.interview_time,
meeting_link=sched.meeting_link or "",
notes=sched.notes or ""
)
# Could log email_sent status if needed
return doc
@router.get("/", response_model=List[ScheduleOut])
async def list_schedules(
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
cursor = db["schedules"].find({"user_id": current_user["google_id"]}).sort("interview_date", 1)
scheds = []
async for doc in cursor:
doc["id"] = str(doc.pop("_id"))
scheds.append(doc)
return scheds
@router.get("/{sched_id}", response_model=ScheduleOut)
async def get_schedule(
sched_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(sched_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
doc = await db["schedules"].find_one({"_id": oid, "user_id": current_user["google_id"]})
if not doc:
raise HTTPException(status_code=404, detail="Schedule not found")
doc["id"] = str(doc.pop("_id"))
return doc
@router.put("/{sched_id}/status", response_model=ScheduleOut)
async def update_schedule_status(
sched_id: str,
status: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(sched_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
result = await db["schedules"].find_one_and_update(
{"_id": oid, "user_id": current_user["google_id"]},
{"$set": {"status": status, "updated_at": datetime.now(timezone.utc)}},
return_document=True
)
if not result:
raise HTTPException(status_code=404, detail="Schedule not found")
result["id"] = str(result.pop("_id"))
return result
@router.delete("/{sched_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_schedule(
sched_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(sched_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
result = await db["schedules"].delete_one({"_id": oid, "user_id": current_user["google_id"]})
if result.deleted_count == 0:
raise HTTPException(status_code=404, detail="Schedule not found")

Binary file not shown.

View file

@ -0,0 +1,51 @@
import logging
from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType
from pydantic import EmailStr
from app.config import get_settings
settings = get_settings()
conf = ConnectionConfig(
MAIL_USERNAME=settings.mail_username,
MAIL_PASSWORD=settings.mail_password,
MAIL_FROM=settings.mail_from,
MAIL_PORT=settings.mail_port,
MAIL_SERVER=settings.mail_server,
MAIL_STARTTLS=settings.mail_tls,
MAIL_SSL_TLS=settings.mail_ssl,
USE_CREDENTIALS=True,
VALIDATE_CERTS=True
)
async def send_interview_email(candidate_email: EmailStr, candidate_name: str, date: str, time: str, meeting_link: str, notes: str):
# Only try to send if credentials are provided
if not settings.mail_username or not settings.mail_password:
logging.warning("Email credentials not configured. Skipping interview email sending.")
return False
html = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; color: #333;">
<h2 style="color: #2563eb;">Interview Scheduled</h2>
<p>Hi {candidate_name},</p>
<p>Your interview has been scheduled for <strong>{date}</strong> at <strong>{time}</strong>.</p>
<p><strong>Meeting Link:</strong> <a href="{meeting_link}">{meeting_link}</a></p>
{f'<p><strong>Notes:</strong> {notes}</p>' if notes else ''}
<br>
<p>Best regards,<br>HireAI Team</p>
</div>
"""
message = MessageSchema(
subject="Interview Invitation - HireAI",
recipients=[candidate_email],
body=html,
subtype=MessageType.html
)
fm = FastMail(conf)
try:
await fm.send_message(message)
return True
except Exception as e:
logging.error(f"Failed to send email: {e}")
return False

View file

@ -0,0 +1,104 @@
import logging
import json
from google import genai
from google.genai import types
from app.config import get_settings
from app.models.question import QuestionItem
from app.models.pronunciation import PronunciationItem
settings = get_settings()
client = None
if settings.gemini_api_key:
client = genai.Client(api_key=settings.gemini_api_key)
async def generate_interview_questions(resume_text: str, role_name: str, skills: list[str], experience: int, projects: list[dict] = None) -> list[dict]:
if not client:
logging.error("Gemini API key not configured.")
raise ValueError("Gemini API key not configured")
# Format projects for the prompt
projects_section = ""
if projects:
projects_section = "\n Candidate's Projects (PRIORITIZE these in your questions):\n"
for p in projects[:5]:
tech_str = ", ".join(p.get("tech", []))
projects_section += f" - {p['name']}: {p.get('description', '')[:300]} (Tech: {tech_str})\n"
else:
projects_section = " No projects listed."
prompt = f"""
You are an expert technical interviewer.
Job Requirement:
- Role: {role_name}
- Required Experience: {experience} years
- Target Skills: {', '.join(skills)}
{projects_section}
Full Candidate Resume Text:
{resume_text}
Generate exactly 5 interview questions following this distribution:
- Questions 1, 2, 3: MUST be directly about the candidate's specific PROJECTS listed above.
Ask about technical decisions made, challenges faced, architecture, or improvements they would make.
- Questions 4, 5: Should cover general skills from the job requirement.
For each question, provide:
1. question: The question itself.
2. expected_answer: Key points an ideal answer should cover.
3. hint: A short hint if the candidate gets stuck.
4. difficulty: Easy, Medium, or Hard.
5. category: Technical, Behavioral, or System Design.
"""
try:
response = client.models.generate_content(
model='gemini-2.5-flash',
contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=list[QuestionItem],
temperature=0.7,
),
)
questions = json.loads(response.text)
return questions
except Exception as e:
logging.error(f"Failed to generate questions: {e}")
raise ValueError(f"Failed to generate questions: {e}")
async def get_word_pronunciation(word: str) -> dict:
if not client:
logging.error("Gemini API key not configured.")
raise ValueError("Gemini API key not configured")
prompt = f"""
You are an expert linguist and technical dictionary.
Analyze the technical term: "{word}"
Provide the following structured information:
1. word: The correctly spelled word.
2. phonetic: The exact IPA (International Phonetic Alphabet) pronunciation (e.g., /ˌpɒlɪˈmɔːfɪzəm/).
3. syllables: An array of strings representing the syllable breakdown (e.g., ["pol", "y", "mor", "phism"]).
4. definition: A concise definition of the word, specifically in a computer science or tech context (max 2 sentences).
"""
try:
response = client.models.generate_content(
model='gemini-2.5-flash',
contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=PronunciationItem,
temperature=0.1,
),
)
# Parse the JSON response
data = json.loads(response.text)
return data
except Exception as e:
logging.error(f"Failed to get pronunciation: {e}")
raise ValueError(f"Failed to get pronunciation: {e}")

View file

@ -0,0 +1,267 @@
"""
Resume parsing service — enhanced extraction.
Supports PDF (via PyMuPDF) and DOCX (via python-docx).
Extracts: name, email, phone, socials, objective, skills,
projects, education, experience, languages, interests.
"""
import re
import io
from typing import Optional
# ── Section header patterns ───────────────────────────────────
SECTION_PATTERNS = {
"objective": r"objective|summary|profile|about me|career goal",
"skills": r"skills|technical skills|competencies|technologies",
"experience": r"experience|work history|employment|professional experience|internship",
"education": r"education|academic|qualification|academics",
"projects": r"projects|portfolio|personal projects|academic projects",
"languages": r"languages|spoken languages",
"interests": r"interests|hobbies|activities|extra.?curricular",
"certifications": r"certifications?|achievements?|awards?",
}
# ── Programming / spoken language keywords ────────────────────
SKILL_KEYWORDS = {
"python", "javascript", "typescript", "java", "c++", "c#", "c", "go", "golang",
"rust", "swift", "kotlin", "scala", "ruby", "php", "r", "matlab", "bash",
"html", "css", "react", "vue", "angular", "next.js", "nuxt", "svelte",
"node.js", "express", "fastapi", "django", "flask", "spring", "laravel",
"machine learning", "deep learning", "tensorflow", "pytorch", "keras",
"scikit-learn", "pandas", "numpy", "matplotlib", "nlp", "computer vision",
"aws", "gcp", "azure", "docker", "kubernetes", "terraform", "jenkins",
"github actions", "ci/cd", "linux",
"mongodb", "postgresql", "mysql", "redis", "elasticsearch", "sqlite",
"dynamodb", "cassandra", "neo4j",
"git", "graphql", "rest api", "microservices", "kafka", "rabbitmq",
"websocket", "grpc", "firebase", "supabase", "prisma", "tailwind",
}
SPOKEN_LANGUAGE_KEYWORDS = {
"english", "hindi", "spanish", "french", "german", "chinese", "mandarin",
"japanese", "korean", "arabic", "portuguese", "italian", "russian",
"telugu", "tamil", "kannada", "bengali", "marathi", "gujarati", "malayalam",
"urdu", "punjabi",
}
# ── Low-level extractors ───────────────────────────────────────
def extract_email(text: str) -> Optional[str]:
m = re.search(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", text)
return m.group(0) if m else None
def extract_phone(text: str) -> Optional[str]:
m = re.search(r"(\+?[\d\s\-().]{10,15})", text)
return m.group(0).strip() if m else None
def extract_linkedin(text: str) -> Optional[str]:
m = re.search(r"linkedin\.com/in/[\w\-]+", text, re.IGNORECASE)
if m:
return "https://" + m.group(0)
return None
def extract_github(text: str) -> Optional[str]:
m = re.search(r"github\.com/[\w\-]+", text, re.IGNORECASE)
if m:
return "https://" + m.group(0)
return None
def extract_website(text: str) -> Optional[str]:
# Match personal URLs that are NOT linkedin/github
m = re.search(
r"https?://(?!.*(?:linkedin|github))[a-zA-Z0-9.\-/~]+",
text,
re.IGNORECASE,
)
return m.group(0) if m else None
def extract_name(text: str) -> Optional[str]:
lines = [l.strip() for l in text.split("\n") if l.strip()]
name_pattern = re.compile(r"^[A-Z][a-z]+(?:\s[A-Z][a-z]+){1,3}$")
for line in lines[:15]:
if name_pattern.match(line):
return line
return None
def extract_skills(text: str) -> list[str]:
text_lower = text.lower()
found = []
for skill in SKILL_KEYWORDS:
if re.search(r"\b" + re.escape(skill) + r"\b", text_lower):
found.append(skill.title() if len(skill) > 3 else skill.upper())
return sorted(set(found))
def extract_languages(section_text: str, full_text: str) -> list[str]:
"""Extract spoken/natural languages (not programming languages)."""
search_text = (section_text or full_text).lower()
found = []
for lang in SPOKEN_LANGUAGE_KEYWORDS:
if re.search(r"\b" + lang + r"\b", search_text):
found.append(lang.capitalize())
return sorted(set(found))
def extract_interests(section_text: str) -> list[str]:
if not section_text:
return []
items = re.split(r"[,\n•\-|/]", section_text)
interests = [i.strip().strip("•-– ") for i in items if i.strip() and len(i.strip()) > 2]
# Deduplicate & cap
seen = set()
result = []
for item in interests:
if item.lower() not in seen and len(item) < 60:
seen.add(item.lower())
result.append(item.title())
return result[:15]
def extract_projects(section_text: str) -> list[dict]:
"""Parse projects section into list of {name, description, tech}."""
if not section_text:
return []
projects = []
# Split by common project delimiters: blank line, bullet, or ALL-CAPS title line
blocks = re.split(r"\n{2,}|(?=\n[•\-★▸])", section_text)
for block in blocks:
block = block.strip()
if not block:
continue
lines = [l.strip() for l in block.split("\n") if l.strip()]
if not lines:
continue
name = lines[0].lstrip("•-★▸ ").strip()
description = " ".join(lines[1:]).strip() if len(lines) > 1 else ""
# Extract tech from description
tech_found = []
desc_lower = description.lower()
for skill in SKILL_KEYWORDS:
if re.search(r"\b" + re.escape(skill) + r"\b", desc_lower):
tech_found.append(skill.title() if len(skill) > 3 else skill.upper())
if name and len(name) < 120:
projects.append({
"name": name,
"description": description[:600] if description else "",
"tech": sorted(set(tech_found)),
})
return projects[:10]
def extract_education(section_text: str) -> list[dict]:
if not section_text:
return []
institutions = []
lines = [l.strip() for l in section_text.split("\n") if l.strip()]
i = 0
while i < len(lines):
line = lines[i]
# Detect degree lines
degree_pattern = re.search(
r"(b\.?tech|m\.?tech|b\.?e|m\.?e|bsc|msc|b\.?sc|m\.?sc|bachelor|master|phd|doctorate|diploma|high school|hsc|ssc|10th|12th)",
line, re.IGNORECASE
)
if degree_pattern:
entry = {
"degree": line,
"institution": lines[i + 1] if i + 1 < len(lines) else "",
"year": "",
}
# Look for year
for j in range(i, min(i + 4, len(lines))):
year_m = re.search(r"\b(19|20)\d{2}\b", lines[j])
if year_m:
entry["year"] = year_m.group(0)
break
institutions.append(entry)
i += 2
else:
i += 1
return institutions[:6]
# ── Section splitter ───────────────────────────────────────────
def split_sections(text: str) -> dict[str, str]:
sections: dict[str, list[str]] = {"header": []}
current = "header"
lines = text.split("\n")
for line in lines:
stripped = line.strip()
matched_section = None
for sec_key, pattern in SECTION_PATTERNS.items():
if re.match(rf"^({pattern})s?[\s:]*$", stripped, re.IGNORECASE):
matched_section = sec_key
break
if matched_section:
current = matched_section
sections.setdefault(current, [])
else:
sections.setdefault(current, []).append(stripped)
return {k: "\n".join(v) for k, v in sections.items()}
# ── Main parse function ────────────────────────────────────────
def parse_text(raw_text: str) -> dict:
sections = split_sections(raw_text)
objective_text = sections.get("objective", "").strip()
return {
"name": extract_name(raw_text),
"email": extract_email(raw_text),
"phone": extract_phone(raw_text),
"linkedin": extract_linkedin(raw_text),
"github": extract_github(raw_text),
"website": extract_website(raw_text),
"objective": objective_text[:600] if objective_text else None,
"skills": extract_skills(raw_text),
"languages": extract_languages(sections.get("languages", ""), raw_text),
"interests": extract_interests(sections.get("interests", "")),
"projects": extract_projects(sections.get("projects", "")),
"education": extract_education(sections.get("education", "")),
"experience": [], # Structured experience parsing can be added as needed
"raw_text": raw_text[:8000], # increased cap for better AI context
}
def parse_pdf(file_bytes: bytes) -> dict:
try:
import fitz # PyMuPDF
doc = fitz.open(stream=file_bytes, filetype="pdf")
text = "\n".join(page.get_text() for page in doc)
doc.close()
return parse_text(text)
except Exception as e:
return {"error": str(e), "raw_text": "", "skills": [], "projects": []}
def parse_docx(file_bytes: bytes) -> dict:
try:
from docx import Document
doc = Document(io.BytesIO(file_bytes))
text = "\n".join(p.text for p in doc.paragraphs)
return parse_text(text)
except Exception as e:
return {"error": str(e), "raw_text": "", "skills": [], "projects": []}

58
backend/main.py Normal file
View file

@ -0,0 +1,58 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.database import connect_db, close_db
from app.config import get_settings
from app.routers import auth, resume, requirements, schedule, questions, pronunciation
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
await connect_db()
yield
await close_db()
app = FastAPI(
title="HireAI API",
description="AI-powered hiring platform backend",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
)
# ── CORS ────────────────────────────────────────────────────────
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_url, "http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Routers ─────────────────────────────────────────────────────
app.include_router(auth.router)
app.include_router(resume.router)
app.include_router(requirements.router)
app.include_router(schedule.router)
app.include_router(questions.router)
app.include_router(pronunciation.router)
@app.get("/")
async def root():
return {
"app": "HireAI API",
"version": "1.0.0",
"status": "running",
"docs": "/docs",
}
@app.get("/health")
async def health():
return {"status": "ok"}

14
backend/requirements.txt Normal file
View file

@ -0,0 +1,14 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
motor==3.6.0
pymongo>=4.9,<4.10
python-multipart==0.0.12
pymupdf==1.24.13
python-docx==1.1.2
httpx==0.27.2
python-jose[cryptography]==3.3.0
python-dotenv==1.0.1
pydantic-settings==2.6.1
pydantic[email]==2.10.3
fastapi-mail==1.4.1
google-genai>=2.3.0