first commit
This commit is contained in:
commit
7a72438fd1
74 changed files with 7548 additions and 0 deletions
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# HireAI app package
|
||||
BIN
backend/app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/config.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/database.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/database.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/dependencies.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/dependencies.cpython-313.pyc
Normal file
Binary file not shown.
40
backend/app/config.py
Normal file
40
backend/app/config.py
Normal 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
39
backend/app/database.py
Normal 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
|
||||
58
backend/app/dependencies.py
Normal file
58
backend/app/dependencies.py
Normal 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
|
||||
BIN
backend/app/models/__pycache__/pronunciation.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/pronunciation.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/question.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/question.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/requirements.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/requirements.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/resume.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/resume.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/schedule.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/schedule.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/user.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/user.cpython-313.pyc
Normal file
Binary file not shown.
13
backend/app/models/pronunciation.py
Normal file
13
backend/app/models/pronunciation.py
Normal 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
|
||||
22
backend/app/models/question.py
Normal file
22
backend/app/models/question.py
Normal 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
|
||||
18
backend/app/models/requirements.py
Normal file
18
backend/app/models/requirements.py
Normal 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
|
||||
31
backend/app/models/resume.py
Normal file
31
backend/app/models/resume.py
Normal 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
|
||||
22
backend/app/models/schedule.py
Normal file
22
backend/app/models/schedule.py
Normal 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
|
||||
19
backend/app/models/user.py
Normal file
19
backend/app/models/user.py
Normal 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
|
||||
3
backend/app/routers/__init__.py
Normal file
3
backend/app/routers/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from app.routers import auth, resume, requirements, schedule, questions, pronunciation
|
||||
|
||||
__all__ = ["auth", "resume", "requirements", "schedule", "questions", "pronunciation"]
|
||||
BIN
backend/app/routers/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/app/routers/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/auth.cpython-313.pyc
Normal file
BIN
backend/app/routers/__pycache__/auth.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/pronunciation.cpython-313.pyc
Normal file
BIN
backend/app/routers/__pycache__/pronunciation.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/questions.cpython-313.pyc
Normal file
BIN
backend/app/routers/__pycache__/questions.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/requirements.cpython-313.pyc
Normal file
BIN
backend/app/routers/__pycache__/requirements.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/resume.cpython-313.pyc
Normal file
BIN
backend/app/routers/__pycache__/resume.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/schedule.cpython-313.pyc
Normal file
BIN
backend/app/routers/__pycache__/schedule.cpython-313.pyc
Normal file
Binary file not shown.
130
backend/app/routers/auth.py
Normal file
130
backend/app/routers/auth.py
Normal 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"})
|
||||
79
backend/app/routers/pronunciation.py
Normal file
79
backend/app/routers/pronunciation.py
Normal 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
|
||||
84
backend/app/routers/questions.py
Normal file
84
backend/app/routers/questions.py
Normal 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
|
||||
98
backend/app/routers/requirements.py
Normal file
98
backend/app/routers/requirements.py
Normal 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")
|
||||
180
backend/app/routers/resume.py
Normal file
180
backend/app/routers/resume.py
Normal 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})
|
||||
110
backend/app/routers/schedule.py
Normal file
110
backend/app/routers/schedule.py
Normal 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")
|
||||
BIN
backend/app/services/__pycache__/email.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/email.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/gemini_service.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/gemini_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/ollama_service.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/ollama_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/resume_parser.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/resume_parser.cpython-313.pyc
Normal file
Binary file not shown.
51
backend/app/services/email.py
Normal file
51
backend/app/services/email.py
Normal 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
|
||||
104
backend/app/services/gemini_service.py
Normal file
104
backend/app/services/gemini_service.py
Normal 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}")
|
||||
267
backend/app/services/resume_parser.py
Normal file
267
backend/app/services/resume_parser.py
Normal 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": []}
|
||||
Loading…
Add table
Add a link
Reference in a new issue