sajith-0701 commited on
Commit
1cff1e5
·
1 Parent(s): ae45104
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ venv
2
+ .env
3
+ .env.local
4
+ node_modules
5
+ __pycache__
6
+ *.pyc
7
+ uploads/
8
+ .next
9
+ dist
10
+ .vercel
backend/auth/__init__.py ADDED
File without changes
backend/auth/jwt.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta, timezone
2
+ from typing import Optional
3
+ from jose import JWTError, jwt
4
+ from fastapi import Depends, HTTPException, status
5
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
6
+ from config import get_settings
7
+
8
+ settings = get_settings()
9
+ security = HTTPBearer()
10
+
11
+
12
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
13
+ to_encode = data.copy()
14
+ expire = datetime.now(timezone.utc) + (expires_delta or timedelta(seconds=settings.JWT_EXPIRY))
15
+ to_encode.update({"exp": expire})
16
+ return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
17
+
18
+
19
+ def verify_token(token: str) -> dict:
20
+ try:
21
+ payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
22
+ return payload
23
+ except JWTError:
24
+ raise HTTPException(
25
+ status_code=status.HTTP_401_UNAUTHORIZED,
26
+ detail="Invalid or expired token",
27
+ )
28
+
29
+
30
+ async def get_current_user(
31
+ credentials: HTTPAuthorizationCredentials = Depends(security),
32
+ ) -> dict:
33
+ payload = verify_token(credentials.credentials)
34
+ user_id = payload.get("sub")
35
+ if not user_id:
36
+ raise HTTPException(status_code=401, detail="Invalid token payload")
37
+ return {
38
+ "user_id": user_id,
39
+ "email": payload.get("email", ""),
40
+ "role": payload.get("role", "student"),
41
+ "name": payload.get("name", ""),
42
+ }
43
+
44
+
45
+ def require_role(role: str):
46
+ async def role_checker(current_user: dict = Depends(get_current_user)):
47
+ if current_user.get("role") != role:
48
+ raise HTTPException(
49
+ status_code=status.HTTP_403_FORBIDDEN,
50
+ detail=f"Access denied. Requires '{role}' role.",
51
+ )
52
+ return current_user
53
+ return role_checker
backend/config.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+ from functools import lru_cache
3
+ import os
4
+ from dotenv import load_dotenv
5
+
6
+ # Load .env from backend directory
7
+ load_dotenv(os.path.join(os.path.dirname(__file__), ".env"))
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ # App
12
+ APP_ENV: str = "production"
13
+ APP_PORT: int = 8000
14
+
15
+ # Gemini
16
+ GEMINI_API_KEY: str
17
+ GEMINI_MODEL: str = "gemini-2.5-flash"
18
+
19
+ # MongoDB Atlas
20
+ MONGO_URI: str
21
+ MONGO_DB_NAME: str = "interview_bot"
22
+
23
+ # Redis
24
+ REDIS_URL: str
25
+
26
+ # JWT
27
+ JWT_SECRET: str
28
+ JWT_ALGORITHM: str = "HS256"
29
+ JWT_EXPIRY: int = 3600
30
+
31
+ # File Storage
32
+ UPLOAD_DIR: str = "./uploads"
33
+
34
+ # Frontend
35
+ NEXT_PUBLIC_API_URL: str = "http://localhost:3000"
36
+
37
+ class Config:
38
+ env_file = ".env"
39
+ extra = "ignore"
40
+
41
+
42
+ @lru_cache()
43
+ def get_settings() -> Settings:
44
+ return Settings()
backend/database.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from motor.motor_asyncio import AsyncIOMotorClient
2
+ import redis.asyncio as aioredis
3
+ from config import get_settings
4
+
5
+ settings = get_settings()
6
+
7
+ # MongoDB Atlas
8
+ mongo_client: AsyncIOMotorClient = None
9
+ db = None
10
+
11
+ # Redis
12
+ redis_client: aioredis.Redis = None
13
+
14
+
15
+ async def connect_db():
16
+ """Initialize MongoDB and Redis connections."""
17
+ global mongo_client, db, redis_client
18
+
19
+ # MongoDB Atlas
20
+ mongo_client = AsyncIOMotorClient(settings.MONGO_URI)
21
+ db = mongo_client[settings.MONGO_DB_NAME]
22
+
23
+ # Create indexes
24
+ await db.users.create_index("email", unique=True)
25
+ await db.resumes.create_index("user_id", unique=True)
26
+ await db.skills.create_index("user_id")
27
+ await db.sessions.create_index("user_id")
28
+ await db.results.create_index("session_id")
29
+ await db.results.create_index("user_id")
30
+ await db.questions.create_index("role_id")
31
+
32
+ # Redis
33
+ redis_client = aioredis.from_url(
34
+ settings.REDIS_URL,
35
+ decode_responses=True,
36
+ )
37
+
38
+ # Test connections
39
+ await mongo_client.admin.command("ping")
40
+ await redis_client.ping()
41
+ print("✅ Connected to MongoDB Atlas and Redis")
42
+
43
+
44
+ async def close_db():
45
+ """Close database connections."""
46
+ global mongo_client, redis_client
47
+ if mongo_client:
48
+ mongo_client.close()
49
+ if redis_client:
50
+ await redis_client.close()
51
+ print("🔌 Database connections closed")
52
+
53
+
54
+ def get_db():
55
+ return db
56
+
57
+
58
+ def get_redis():
59
+ return redis_client
backend/main.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+ from fastapi import FastAPI
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from fastapi.staticfiles import StaticFiles
5
+ import os
6
+
7
+ from config import get_settings
8
+ from database import connect_db, close_db
9
+
10
+ from routers import auth, resume, profile, interview, reports, admin
11
+
12
+ settings = get_settings()
13
+
14
+
15
+ @asynccontextmanager
16
+ async def lifespan(app: FastAPI):
17
+ # Startup
18
+ await connect_db()
19
+ os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
20
+ print(f"🚀 Interview Bot API running in {settings.APP_ENV} mode")
21
+ yield
22
+ # Shutdown
23
+ await close_db()
24
+
25
+
26
+ app = FastAPI(
27
+ title="AI Mock Interview Trainer",
28
+ description="Production-ready AI-powered mock interview platform",
29
+ version="1.0.0",
30
+ lifespan=lifespan,
31
+ )
32
+
33
+ # CORS
34
+ app.add_middleware(
35
+ CORSMiddleware,
36
+ allow_origins=["*"],
37
+ allow_credentials=True,
38
+ allow_methods=["*"],
39
+ allow_headers=["*"],
40
+ )
41
+
42
+ # Routers
43
+ app.include_router(auth.router, prefix="/auth", tags=["Authentication"])
44
+ app.include_router(resume.router, prefix="/resume", tags=["Resume"])
45
+ app.include_router(profile.router, prefix="/profile", tags=["Profile"])
46
+ app.include_router(interview.router, prefix="/interview", tags=["Interview"])
47
+ app.include_router(reports.router, prefix="/reports", tags=["Reports"])
48
+ app.include_router(admin.router, prefix="/admin", tags=["Admin"])
49
+
50
+
51
+ @app.get("/health")
52
+ async def health_check():
53
+ return {"status": "healthy", "version": "1.0.0"}
backend/models/__init__.py ADDED
File without changes
backend/models/collections.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MongoDB collection name constants
2
+
3
+ USERS = "users"
4
+ RESUMES = "resumes"
5
+ SKILLS = "skills"
6
+ JOB_ROLES = "job_roles"
7
+ ROLE_REQUIREMENTS = "role_requirements"
8
+ QUESTIONS = "questions"
9
+ SESSIONS = "sessions"
10
+ ANSWERS = "answers"
11
+ RESULTS = "results"
backend/requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.0
3
+ motor==3.5.0
4
+ redis[hiredis]==5.0.0
5
+ python-jose[cryptography]==3.3.0
6
+ passlib[bcrypt]==1.7.4
7
+ bcrypt==4.0.1
8
+ python-multipart==0.0.9
9
+ google-genai==1.5.0
10
+ langgraph==0.2.0
11
+ pydantic-settings==2.5.0
12
+ python-dotenv==1.0.1
13
+ aiofiles==24.1.0
backend/routers/__init__.py ADDED
File without changes
backend/routers/admin.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Query
2
+ from auth.jwt import require_role, get_current_user
3
+ from schemas.admin import (
4
+ JobRoleCreate, JobRoleUpdate,
5
+ QuestionCreate, QuestionUpdate,
6
+ RoleRequirementCreate,
7
+ )
8
+ from services.admin_service import (
9
+ create_role, update_role, delete_role, list_roles,
10
+ create_question, update_question, delete_question, list_questions,
11
+ create_requirement, list_requirements, delete_requirement,
12
+ )
13
+ from services.analytics_service import get_admin_analytics
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ # ─── Job Roles ───
19
+
20
+ @router.get("/roles")
21
+ async def get_roles(current_user: dict = Depends(get_current_user)):
22
+ """List all job roles (accessible by all authenticated users for interview selection)."""
23
+ roles = await list_roles()
24
+ return {"roles": roles}
25
+
26
+
27
+ @router.post("/roles")
28
+ async def create_role_endpoint(
29
+ request: JobRoleCreate,
30
+ current_user: dict = Depends(require_role("admin")),
31
+ ):
32
+ """Create a new job role (admin only)."""
33
+ result = await create_role(
34
+ title=request.title,
35
+ description=request.description,
36
+ department=request.department,
37
+ )
38
+ return result
39
+
40
+
41
+ @router.put("/roles/{role_id}")
42
+ async def update_role_endpoint(
43
+ role_id: str,
44
+ request: JobRoleUpdate,
45
+ current_user: dict = Depends(require_role("admin")),
46
+ ):
47
+ """Update a job role (admin only)."""
48
+ try:
49
+ result = await update_role(role_id, request.model_dump())
50
+ return result
51
+ except ValueError as e:
52
+ raise HTTPException(status_code=400, detail=str(e))
53
+
54
+
55
+ @router.delete("/roles/{role_id}")
56
+ async def delete_role_endpoint(
57
+ role_id: str,
58
+ current_user: dict = Depends(require_role("admin")),
59
+ ):
60
+ """Delete a job role (admin only)."""
61
+ success = await delete_role(role_id)
62
+ if not success:
63
+ raise HTTPException(status_code=404, detail="Role not found")
64
+ return {"message": "Role deleted"}
65
+
66
+
67
+ # ─── Questions ───
68
+
69
+ @router.get("/questions")
70
+ async def get_questions(
71
+ role_id: str = Query(None),
72
+ current_user: dict = Depends(require_role("admin")),
73
+ ):
74
+ """List questions, optionally filtered by role."""
75
+ questions = await list_questions(role_id)
76
+ return {"questions": questions}
77
+
78
+
79
+ @router.post("/questions")
80
+ async def create_question_endpoint(
81
+ request: QuestionCreate,
82
+ current_user: dict = Depends(require_role("admin")),
83
+ ):
84
+ """Create a new question (admin only)."""
85
+ result = await create_question(
86
+ role_id=request.role_id,
87
+ question=request.question,
88
+ difficulty=request.difficulty,
89
+ category=request.category,
90
+ expected_answer=request.expected_answer,
91
+ )
92
+ return result
93
+
94
+
95
+ @router.put("/questions/{question_id}")
96
+ async def update_question_endpoint(
97
+ question_id: str,
98
+ request: QuestionUpdate,
99
+ current_user: dict = Depends(require_role("admin")),
100
+ ):
101
+ """Update a question (admin only)."""
102
+ try:
103
+ result = await update_question(question_id, request.model_dump())
104
+ return result
105
+ except ValueError as e:
106
+ raise HTTPException(status_code=400, detail=str(e))
107
+
108
+
109
+ @router.delete("/questions/{question_id}")
110
+ async def delete_question_endpoint(
111
+ question_id: str,
112
+ current_user: dict = Depends(require_role("admin")),
113
+ ):
114
+ """Delete a question (admin only)."""
115
+ success = await delete_question(question_id)
116
+ if not success:
117
+ raise HTTPException(status_code=404, detail="Question not found")
118
+ return {"message": "Question deleted"}
119
+
120
+
121
+ # ─── Role Requirements ───
122
+
123
+ @router.get("/requirements/{role_id}")
124
+ async def get_requirements(
125
+ role_id: str,
126
+ current_user: dict = Depends(require_role("admin")),
127
+ ):
128
+ """List requirements for a role."""
129
+ requirements = await list_requirements(role_id)
130
+ return {"requirements": requirements}
131
+
132
+
133
+ @router.post("/requirements")
134
+ async def create_requirement_endpoint(
135
+ request: RoleRequirementCreate,
136
+ current_user: dict = Depends(require_role("admin")),
137
+ ):
138
+ """Create a role requirement (admin only)."""
139
+ result = await create_requirement(
140
+ role_id=request.role_id,
141
+ skill=request.skill,
142
+ level=request.level,
143
+ )
144
+ return result
145
+
146
+
147
+ @router.delete("/requirements/{req_id}")
148
+ async def delete_requirement_endpoint(
149
+ req_id: str,
150
+ current_user: dict = Depends(require_role("admin")),
151
+ ):
152
+ """Delete a role requirement (admin only)."""
153
+ success = await delete_requirement(req_id)
154
+ if not success:
155
+ raise HTTPException(status_code=404, detail="Requirement not found")
156
+ return {"message": "Requirement deleted"}
157
+
158
+
159
+ # ─── Analytics ───
160
+
161
+ @router.get("/analytics")
162
+ async def get_analytics(
163
+ current_user: dict = Depends(require_role("admin")),
164
+ ):
165
+ """Get admin analytics dashboard data."""
166
+ analytics = await get_admin_analytics()
167
+ return analytics
backend/routers/auth.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from schemas.auth import SignupRequest, LoginRequest, AuthResponse
3
+ from services.auth_service import signup_user, login_user
4
+
5
+ router = APIRouter()
6
+
7
+
8
+ @router.post("/signup", response_model=AuthResponse)
9
+ async def signup(request: SignupRequest):
10
+ """Register a new user."""
11
+ try:
12
+ result = await signup_user(
13
+ name=request.name,
14
+ email=request.email,
15
+ password=request.password,
16
+ )
17
+ return result
18
+ except ValueError as e:
19
+ raise HTTPException(status_code=400, detail=str(e))
20
+
21
+
22
+ @router.post("/login", response_model=AuthResponse)
23
+ async def login(request: LoginRequest):
24
+ """Authenticate and get JWT token."""
25
+ try:
26
+ result = await login_user(email=request.email, password=request.password)
27
+ return result
28
+ except ValueError as e:
29
+ raise HTTPException(status_code=401, detail=str(e))
backend/routers/interview.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from auth.jwt import get_current_user
3
+ from schemas.interview import (
4
+ StartInterviewRequest,
5
+ SubmitAnswerRequest,
6
+ InterviewStartResponse,
7
+ AnswerResponse,
8
+ )
9
+ from services.interview_service import start_interview, submit_answer
10
+ from services.evaluation_service import generate_report
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ @router.post("/start")
16
+ async def start_interview_endpoint(
17
+ request: StartInterviewRequest,
18
+ current_user: dict = Depends(get_current_user),
19
+ ):
20
+ """Start a new interview session."""
21
+ try:
22
+ result = await start_interview(
23
+ user_id=current_user["user_id"],
24
+ role_id=request.role_id,
25
+ )
26
+ return result
27
+ except Exception as e:
28
+ raise HTTPException(status_code=500, detail=str(e))
29
+
30
+
31
+ @router.post("/answer")
32
+ async def submit_answer_endpoint(
33
+ request: SubmitAnswerRequest,
34
+ current_user: dict = Depends(get_current_user),
35
+ ):
36
+ """Submit an answer and get next question."""
37
+ try:
38
+ result = await submit_answer(
39
+ session_id=request.session_id,
40
+ question_id=request.question_id,
41
+ answer=request.answer,
42
+ )
43
+ return result
44
+ except ValueError as e:
45
+ raise HTTPException(status_code=400, detail=str(e))
46
+ except Exception as e:
47
+ raise HTTPException(status_code=500, detail=str(e))
48
+
49
+
50
+ @router.get("/report")
51
+ async def get_interview_report(
52
+ session_id: str,
53
+ current_user: dict = Depends(get_current_user),
54
+ ):
55
+ """Generate and retrieve interview report."""
56
+ try:
57
+ result = await generate_report(
58
+ session_id=session_id,
59
+ user_id=current_user["user_id"],
60
+ )
61
+ return result
62
+ except ValueError as e:
63
+ raise HTTPException(status_code=400, detail=str(e))
64
+ except Exception as e:
65
+ raise HTTPException(status_code=500, detail=str(e))
backend/routers/profile.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from auth.jwt import get_current_user
3
+ from database import get_db
4
+ from models.collections import USERS, RESUMES, SKILLS
5
+ from utils.helpers import str_objectid
6
+ from bson import ObjectId
7
+
8
+ router = APIRouter()
9
+
10
+
11
+ @router.get("")
12
+ async def get_profile(current_user: dict = Depends(get_current_user)):
13
+ """Get current user's profile with skills and resume info."""
14
+ db = get_db()
15
+
16
+ user = await db[USERS].find_one({"_id": ObjectId(current_user["user_id"])})
17
+ if not user:
18
+ # Fallback: try finding by email
19
+ user = await db[USERS].find_one({"email": current_user["email"]})
20
+
21
+ profile = {
22
+ "user_id": current_user["user_id"],
23
+ "name": current_user.get("name", ""),
24
+ "email": current_user.get("email", ""),
25
+ "role": current_user.get("role", "student"),
26
+ }
27
+
28
+ # Get resume info
29
+ resume = await db[RESUMES].find_one({"user_id": current_user["user_id"]})
30
+ if resume:
31
+ profile["resume"] = {
32
+ "filename": resume.get("original_filename", ""),
33
+ "uploaded_at": resume.get("uploaded_at", ""),
34
+ "parsed_text": resume.get("parsed_text", ""),
35
+ }
36
+ else:
37
+ profile["resume"] = None
38
+
39
+ # Get skills
40
+ skills_doc = await db[SKILLS].find_one({"user_id": current_user["user_id"]})
41
+ profile["skills"] = skills_doc.get("skills", []) if skills_doc else []
42
+
43
+ return profile
44
+
45
+ @router.put("/skills")
46
+ async def update_user_skills(
47
+ request_data: dict, # Or use UpdateSkillsRequest if imported
48
+ current_user: dict = Depends(get_current_user)
49
+ ):
50
+ """Update the current user's extracted skills."""
51
+ db = get_db()
52
+ skills = request_data.get("skills", [])
53
+
54
+ # Upsert the skills document for this user
55
+ await db[SKILLS].update_one(
56
+ {"user_id": current_user["user_id"]},
57
+ {"$set": {"skills": skills, "user_id": current_user["user_id"]}},
58
+ upsert=True
59
+ )
60
+
61
+ return {"message": "Skills updated successfully", "skills": skills}
backend/routers/reports.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from auth.jwt import get_current_user
3
+ from services.analytics_service import get_student_history
4
+
5
+ router = APIRouter()
6
+
7
+
8
+ @router.get("/history")
9
+ async def get_reports_history(current_user: dict = Depends(get_current_user)):
10
+ """Get student's interview history."""
11
+ history = await get_student_history(current_user["user_id"])
12
+ return {"reports": history}
backend/routers/resume.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
2
+ from auth.jwt import get_current_user
3
+ from services.resume_service import upload_and_parse_resume
4
+
5
+ router = APIRouter()
6
+
7
+
8
+ @router.post("/upload")
9
+ async def upload_resume(
10
+ file: UploadFile = File(...),
11
+ current_user: dict = Depends(get_current_user),
12
+ ):
13
+ """Upload and parse a resume using Gemini AI."""
14
+ if not file.filename:
15
+ raise HTTPException(status_code=400, detail="No file provided")
16
+
17
+ allowed_types = [
18
+ "application/pdf",
19
+ "text/plain",
20
+ "application/msword",
21
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
22
+ ]
23
+
24
+ content = await file.read()
25
+ if len(content) > 5 * 1024 * 1024: # 5MB limit
26
+ raise HTTPException(status_code=400, detail="File too large. Maximum 5MB.")
27
+
28
+ try:
29
+ result = await upload_and_parse_resume(
30
+ user_id=current_user["user_id"],
31
+ filename=file.filename,
32
+ file_content=content,
33
+ )
34
+ return result
35
+ except Exception as e:
36
+ raise HTTPException(status_code=500, detail=f"Failed to process resume: {str(e)}")
backend/schemas/__init__.py ADDED
File without changes
backend/schemas/admin.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional, List
3
+
4
+
5
+ class JobRoleCreate(BaseModel):
6
+ title: str
7
+ description: str
8
+ department: Optional[str] = None
9
+
10
+
11
+ class JobRoleUpdate(BaseModel):
12
+ title: Optional[str] = None
13
+ description: Optional[str] = None
14
+ department: Optional[str] = None
15
+
16
+
17
+ class JobRoleResponse(BaseModel):
18
+ id: str
19
+ title: str
20
+ description: str
21
+ department: Optional[str] = None
22
+ created_at: str
23
+
24
+
25
+ class QuestionCreate(BaseModel):
26
+ role_id: str
27
+ question: str
28
+ difficulty: str = "medium"
29
+ category: Optional[str] = None
30
+ expected_answer: Optional[str] = None
31
+
32
+
33
+ class QuestionUpdate(BaseModel):
34
+ question: Optional[str] = None
35
+ difficulty: Optional[str] = None
36
+ category: Optional[str] = None
37
+ expected_answer: Optional[str] = None
38
+
39
+
40
+ class QuestionResponse(BaseModel):
41
+ id: str
42
+ role_id: str
43
+ question: str
44
+ difficulty: str
45
+ category: Optional[str] = None
46
+ created_at: str
47
+
48
+
49
+ class RoleRequirementCreate(BaseModel):
50
+ role_id: str
51
+ skill: str
52
+ level: str = "intermediate"
53
+
54
+
55
+ class RoleRequirementResponse(BaseModel):
56
+ id: str
57
+ role_id: str
58
+ skill: str
59
+ level: str
backend/schemas/analytics.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import List, Optional, Dict
3
+
4
+
5
+ class StudentAnalytics(BaseModel):
6
+ user_id: str
7
+ name: str
8
+ email: str
9
+ total_interviews: int
10
+ average_score: float
11
+ best_score: int
12
+ worst_score: int
13
+ weak_topics: List[str]
14
+ strong_topics: List[str]
15
+
16
+
17
+ class OverallAnalytics(BaseModel):
18
+ total_students: int
19
+ total_interviews: int
20
+ average_score: float
21
+ top_performers: List[Dict]
22
+ common_weak_areas: List[str]
23
+
24
+
25
+ class ReportHistory(BaseModel):
26
+ session_id: str
27
+ overall_score: int
28
+ total_questions: int
29
+ completed_at: str
30
+ role_title: Optional[str] = None
backend/schemas/auth.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, EmailStr
2
+ from typing import Optional
3
+
4
+
5
+ class SignupRequest(BaseModel):
6
+ name: str
7
+ email: EmailStr
8
+ password: str
9
+
10
+
11
+ class LoginRequest(BaseModel):
12
+ email: EmailStr
13
+ password: str
14
+
15
+
16
+ class AuthResponse(BaseModel):
17
+ access_token: str
18
+ token_type: str = "bearer"
19
+ user: dict
backend/schemas/interview.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional, List, Dict
3
+
4
+
5
+ class StartInterviewRequest(BaseModel):
6
+ role_id: Optional[str] = None
7
+
8
+
9
+ class SubmitAnswerRequest(BaseModel):
10
+ session_id: str
11
+ question_id: str
12
+ answer: str
13
+
14
+
15
+ class InterviewQuestion(BaseModel):
16
+ question_id: str
17
+ question: str
18
+ difficulty: str = "medium"
19
+ question_number: int = 1
20
+ total_questions: int = 10
21
+
22
+
23
+ class InterviewStartResponse(BaseModel):
24
+ session_id: str
25
+ question: InterviewQuestion
26
+ message: str = "Interview started"
27
+
28
+
29
+ class AnswerResponse(BaseModel):
30
+ session_id: str
31
+ next_question: Optional[InterviewQuestion] = None
32
+ is_complete: bool = False
33
+ message: str = ""
34
+
35
+
36
+ class QuestionScore(BaseModel):
37
+ question: str
38
+ answer: str
39
+ score: int
40
+ feedback: str
41
+
42
+
43
+ class InterviewReport(BaseModel):
44
+ session_id: str
45
+ overall_score: int
46
+ total_questions: int
47
+ strengths: List[str]
48
+ weaknesses: List[str]
49
+ detailed_scores: List[QuestionScore]
50
+ recommendations: List[str]
51
+ completed_at: str
backend/schemas/resume.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional, List
3
+
4
+
5
+ class ResumeResponse(BaseModel):
6
+ id: str
7
+ user_id: str
8
+ filename: str
9
+ parsed_text: Optional[str] = None
10
+ skills: List[str] = []
11
+ uploaded_at: str
12
+
13
+ class UpdateSkillsRequest(BaseModel):
14
+ skills: List[str]
backend/services/__init__.py ADDED
File without changes
backend/services/admin_service.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from bson import ObjectId
2
+ from database import get_db
3
+ from models.collections import JOB_ROLES, ROLE_REQUIREMENTS, QUESTIONS
4
+ from utils.helpers import utc_now, str_objectid, str_objectids
5
+
6
+
7
+ # ─── Job Roles ───
8
+
9
+ async def create_role(title: str, description: str, department: str = None) -> dict:
10
+ db = get_db()
11
+ doc = {
12
+ "title": title,
13
+ "description": description,
14
+ "department": department,
15
+ "created_at": utc_now(),
16
+ }
17
+ result = await db[JOB_ROLES].insert_one(doc)
18
+ doc["_id"] = result.inserted_id
19
+ return str_objectid(doc)
20
+
21
+
22
+ async def update_role(role_id: str, data: dict) -> dict:
23
+ db = get_db()
24
+ update_data = {k: v for k, v in data.items() if v is not None}
25
+ if not update_data:
26
+ raise ValueError("No fields to update")
27
+ update_data["updated_at"] = utc_now()
28
+ await db[JOB_ROLES].update_one({"_id": ObjectId(role_id)}, {"$set": update_data})
29
+ doc = await db[JOB_ROLES].find_one({"_id": ObjectId(role_id)})
30
+ if not doc:
31
+ raise ValueError("Role not found")
32
+ return str_objectid(doc)
33
+
34
+
35
+ async def delete_role(role_id: str) -> bool:
36
+ db = get_db()
37
+ result = await db[JOB_ROLES].delete_one({"_id": ObjectId(role_id)})
38
+ return result.deleted_count > 0
39
+
40
+
41
+ async def list_roles() -> list:
42
+ db = get_db()
43
+ cursor = db[JOB_ROLES].find().sort("created_at", -1)
44
+ docs = await cursor.to_list(length=100)
45
+ return str_objectids(docs)
46
+
47
+
48
+ # ─── Questions ───
49
+
50
+ async def create_question(role_id: str, question: str, difficulty: str = "medium",
51
+ category: str = None, expected_answer: str = None) -> dict:
52
+ db = get_db()
53
+ doc = {
54
+ "role_id": role_id,
55
+ "question": question,
56
+ "difficulty": difficulty,
57
+ "category": category,
58
+ "expected_answer": expected_answer,
59
+ "created_at": utc_now(),
60
+ }
61
+ result = await db[QUESTIONS].insert_one(doc)
62
+ doc["_id"] = result.inserted_id
63
+ return str_objectid(doc)
64
+
65
+
66
+ async def update_question(question_id: str, data: dict) -> dict:
67
+ db = get_db()
68
+ update_data = {k: v for k, v in data.items() if v is not None}
69
+ if not update_data:
70
+ raise ValueError("No fields to update")
71
+ update_data["updated_at"] = utc_now()
72
+ await db[QUESTIONS].update_one({"_id": ObjectId(question_id)}, {"$set": update_data})
73
+ doc = await db[QUESTIONS].find_one({"_id": ObjectId(question_id)})
74
+ if not doc:
75
+ raise ValueError("Question not found")
76
+ return str_objectid(doc)
77
+
78
+
79
+ async def delete_question(question_id: str) -> bool:
80
+ db = get_db()
81
+ result = await db[QUESTIONS].delete_one({"_id": ObjectId(question_id)})
82
+ return result.deleted_count > 0
83
+
84
+
85
+ async def list_questions(role_id: str = None) -> list:
86
+ db = get_db()
87
+ query = {"role_id": role_id} if role_id else {}
88
+ cursor = db[QUESTIONS].find(query).sort("created_at", -1)
89
+ docs = await cursor.to_list(length=200)
90
+ return str_objectids(docs)
91
+
92
+
93
+ # ─── Role Requirements ───
94
+
95
+ async def create_requirement(role_id: str, skill: str, level: str = "intermediate") -> dict:
96
+ db = get_db()
97
+ doc = {
98
+ "role_id": role_id,
99
+ "skill": skill,
100
+ "level": level,
101
+ "created_at": utc_now(),
102
+ }
103
+ result = await db[ROLE_REQUIREMENTS].insert_one(doc)
104
+ doc["_id"] = result.inserted_id
105
+ return str_objectid(doc)
106
+
107
+
108
+ async def list_requirements(role_id: str) -> list:
109
+ db = get_db()
110
+ cursor = db[ROLE_REQUIREMENTS].find({"role_id": role_id})
111
+ docs = await cursor.to_list(length=100)
112
+ return str_objectids(docs)
113
+
114
+
115
+ async def delete_requirement(req_id: str) -> bool:
116
+ db = get_db()
117
+ result = await db[ROLE_REQUIREMENTS].delete_one({"_id": ObjectId(req_id)})
118
+ return result.deleted_count > 0
backend/services/analytics_service.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from database import get_db
2
+ from models.collections import RESULTS, SESSIONS, USERS
3
+ from utils.helpers import str_objectid, str_objectids
4
+
5
+
6
+ async def get_student_history(user_id: str) -> list:
7
+ """Get all interview reports for a student."""
8
+ db = get_db()
9
+ cursor = db[RESULTS].find({"user_id": user_id}).sort("completed_at", -1)
10
+ docs = await cursor.to_list(length=50)
11
+ results = []
12
+ for doc in docs:
13
+ results.append({
14
+ "session_id": doc.get("session_id"),
15
+ "overall_score": doc.get("overall_score", 0),
16
+ "total_questions": doc.get("total_questions", 0),
17
+ "completed_at": doc.get("completed_at", ""),
18
+ "role_title": doc.get("role_title", ""),
19
+ })
20
+ return results
21
+
22
+
23
+ async def get_admin_analytics() -> dict:
24
+ """Get aggregated analytics for admin dashboard."""
25
+ db = get_db()
26
+
27
+ # Total students
28
+ total_students = await db[USERS].count_documents({"role": "student"})
29
+
30
+ # Total interviews
31
+ total_interviews = await db[RESULTS].count_documents({})
32
+
33
+ # Average score
34
+ pipeline = [
35
+ {"$group": {"_id": None, "avg_score": {"$avg": "$overall_score"}}},
36
+ ]
37
+ avg_result = await db[RESULTS].aggregate(pipeline).to_list(length=1)
38
+ avg_score = round(avg_result[0]["avg_score"], 1) if avg_result else 0
39
+
40
+ # Top performers
41
+ top_pipeline = [
42
+ {"$group": {
43
+ "_id": "$user_id",
44
+ "avg_score": {"$avg": "$overall_score"},
45
+ "interview_count": {"$sum": 1},
46
+ }},
47
+ {"$sort": {"avg_score": -1}},
48
+ {"$limit": 10},
49
+ ]
50
+ top_results = await db[RESULTS].aggregate(top_pipeline).to_list(length=10)
51
+
52
+ top_performers = []
53
+ for r in top_results:
54
+ user = await db[USERS].find_one({"_id": __import__("bson").ObjectId(r["_id"])})
55
+ if not user:
56
+ # user_id might be stored as string
57
+ user = await db[USERS].find_one({"email": {"$exists": True}})
58
+ top_performers.append({
59
+ "user_id": r["_id"],
60
+ "name": user.get("name", "Unknown") if user else "Unknown",
61
+ "avg_score": round(r["avg_score"], 1),
62
+ "interview_count": r["interview_count"],
63
+ })
64
+
65
+ # Common weak areas
66
+ weakness_pipeline = [
67
+ {"$unwind": "$weaknesses"},
68
+ {"$group": {"_id": "$weaknesses", "count": {"$sum": 1}}},
69
+ {"$sort": {"count": -1}},
70
+ {"$limit": 10},
71
+ ]
72
+ weakness_results = await db[RESULTS].aggregate(weakness_pipeline).to_list(length=10)
73
+ common_weak = [w["_id"] for w in weakness_results]
74
+
75
+ return {
76
+ "total_students": total_students,
77
+ "total_interviews": total_interviews,
78
+ "average_score": avg_score,
79
+ "top_performers": top_performers,
80
+ "common_weak_areas": common_weak,
81
+ }
82
+
83
+
84
+ async def get_student_analytics(user_id: str) -> dict:
85
+ """Get analytics for a specific student."""
86
+ db = get_db()
87
+
88
+ results = await db[RESULTS].find({"user_id": user_id}).to_list(length=100)
89
+ if not results:
90
+ return {
91
+ "total_interviews": 0,
92
+ "average_score": 0,
93
+ "best_score": 0,
94
+ "worst_score": 0,
95
+ "weak_topics": [],
96
+ "strong_topics": [],
97
+ }
98
+
99
+ scores = [r.get("overall_score", 0) for r in results]
100
+ all_weaknesses = []
101
+ all_strengths = []
102
+ for r in results:
103
+ all_weaknesses.extend(r.get("weaknesses", []))
104
+ all_strengths.extend(r.get("strengths", []))
105
+
106
+ # Count frequencies
107
+ from collections import Counter
108
+ weak_counts = Counter(all_weaknesses)
109
+ strong_counts = Counter(all_strengths)
110
+
111
+ return {
112
+ "total_interviews": len(results),
113
+ "average_score": round(sum(scores) / len(scores), 1),
114
+ "best_score": max(scores),
115
+ "worst_score": min(scores),
116
+ "weak_topics": [w for w, _ in weak_counts.most_common(5)],
117
+ "strong_topics": [s for s, _ in strong_counts.most_common(5)],
118
+ }
backend/services/auth_service.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from passlib.context import CryptContext
2
+ from database import get_db
3
+ from models.collections import USERS
4
+ from utils.helpers import utc_now, str_objectid
5
+ from auth.jwt import create_access_token
6
+
7
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
8
+
9
+
10
+ async def signup_user(name: str, email: str, password: str, role: str = None) -> dict:
11
+ """Register a new user."""
12
+ db = get_db()
13
+
14
+ # Check if user exists
15
+ existing = await db[USERS].find_one({"email": email})
16
+ if existing:
17
+ raise ValueError("User with this email already exists")
18
+
19
+ # Enforce role logic
20
+ determined_role = "admin" if email.endswith("@admin.com") else "student"
21
+
22
+ hashed_password = pwd_context.hash(password)
23
+ user_doc = {
24
+ "name": name,
25
+ "email": email,
26
+ "password": hashed_password,
27
+ "role": determined_role,
28
+ "created_at": utc_now(),
29
+ }
30
+
31
+ result = await db[USERS].insert_one(user_doc)
32
+ user_doc["_id"] = result.inserted_id
33
+ user = str_objectid(user_doc)
34
+ del user["password"]
35
+
36
+ token = create_access_token({
37
+ "sub": user["id"],
38
+ "email": user["email"],
39
+ "role": user["role"],
40
+ "name": user["name"],
41
+ })
42
+
43
+ return {"access_token": token, "token_type": "bearer", "user": user}
44
+
45
+
46
+ async def login_user(email: str, password: str) -> dict:
47
+ """Authenticate a user and return JWT."""
48
+ db = get_db()
49
+
50
+ user_doc = await db[USERS].find_one({"email": email})
51
+ if not user_doc:
52
+ raise ValueError("Invalid email or password")
53
+
54
+ if not pwd_context.verify(password, user_doc["password"]):
55
+ raise ValueError("Invalid email or password")
56
+
57
+ user = str_objectid(user_doc)
58
+ del user["password"]
59
+
60
+ token = create_access_token({
61
+ "sub": user["id"],
62
+ "email": user["email"],
63
+ "role": user["role"],
64
+ "name": user["name"],
65
+ })
66
+
67
+ return {"access_token": token, "token_type": "bearer", "user": user}
backend/services/evaluation_service.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from database import get_db, get_redis
2
+ from models.collections import RESULTS, ANSWERS, SESSIONS
3
+ from utils.helpers import utc_now
4
+ from utils.gemini import evaluate_interview
5
+ from services.interview_service import get_session_qa
6
+
7
+
8
+ async def generate_report(session_id: str, user_id: str) -> dict:
9
+ """Generate final evaluation report from Redis Q&A data using Gemini."""
10
+ db = get_db()
11
+ redis = get_redis()
12
+
13
+ # Check if report already exists
14
+ existing = await db[RESULTS].find_one({"session_id": session_id})
15
+ if existing:
16
+ existing["id"] = str(existing["_id"])
17
+ del existing["_id"]
18
+ return existing
19
+
20
+ # Get session info
21
+ session = await db[SESSIONS].find_one({"session_id": session_id})
22
+ if not session:
23
+ raise ValueError("Session not found")
24
+
25
+ if session.get("user_id") != user_id:
26
+ raise ValueError("Unauthorized access to session")
27
+
28
+ role_title = session.get("role_title", "Software Developer")
29
+
30
+ # Get all Q&A from Redis
31
+ qa_pairs = await get_session_qa(session_id)
32
+ if not qa_pairs:
33
+ raise ValueError("No Q&A data found for this session")
34
+
35
+ # Batch evaluate with Gemini
36
+ evaluation = await evaluate_interview(qa_pairs, role_title)
37
+
38
+ # Store results in MongoDB
39
+ result_doc = {
40
+ "session_id": session_id,
41
+ "user_id": user_id,
42
+ "role_title": role_title,
43
+ "overall_score": evaluation.get("overall_score", 0),
44
+ "total_questions": len(qa_pairs),
45
+ "detailed_scores": evaluation.get("detailed_scores", []),
46
+ "strengths": evaluation.get("strengths", []),
47
+ "weaknesses": evaluation.get("weaknesses", []),
48
+ "recommendations": evaluation.get("recommendations", []),
49
+ "completed_at": utc_now(),
50
+ }
51
+ await db[RESULTS].insert_one(result_doc)
52
+
53
+ # Store final answers in MongoDB
54
+ for qa in qa_pairs:
55
+ answer_doc = {
56
+ "session_id": session_id,
57
+ "user_id": user_id,
58
+ "question_id": qa["question_id"],
59
+ "question": qa["question"],
60
+ "answer": qa["answer"],
61
+ "difficulty": qa["difficulty"],
62
+ "category": qa["category"],
63
+ "stored_at": utc_now(),
64
+ }
65
+ await db[ANSWERS].insert_one(answer_doc)
66
+
67
+ # Clean up Redis session data
68
+ question_ids = await redis.lrange(f"session:{session_id}:questions", 0, -1)
69
+ keys_to_delete = [
70
+ f"session:{session_id}",
71
+ f"session:{session_id}:questions",
72
+ f"session:{session_id}:answers",
73
+ ]
74
+ for qid in question_ids:
75
+ keys_to_delete.append(f"session:{session_id}:q:{qid}")
76
+ keys_to_delete.append(f"session:{session_id}:a:{qid}")
77
+
78
+ if keys_to_delete:
79
+ await redis.delete(*keys_to_delete)
80
+
81
+ result_doc["id"] = str(result_doc["_id"])
82
+ del result_doc["_id"]
83
+ return result_doc
backend/services/interview_service.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from database import get_db, get_redis
3
+ from models.collections import SESSIONS, JOB_ROLES, SKILLS, QUESTIONS
4
+ from utils.helpers import generate_id, utc_now, str_objectid
5
+ from utils.gemini import generate_interview_question
6
+
7
+ MAX_QUESTIONS = 10
8
+ SESSION_TTL = 7200 # 2 hours
9
+
10
+
11
+ async def start_interview(user_id: str, role_id: str = None) -> dict:
12
+ """Start a new interview session."""
13
+ db = get_db()
14
+ redis = get_redis()
15
+
16
+ # Get user skills
17
+ skills_doc = await db[SKILLS].find_one({"user_id": user_id})
18
+ skills = skills_doc.get("skills", ["general"]) if skills_doc else ["general"]
19
+
20
+ # Get role
21
+ role_title = "Software Developer"
22
+ if role_id:
23
+ from bson import ObjectId
24
+ role = await db[JOB_ROLES].find_one({"_id": ObjectId(role_id)})
25
+ if role:
26
+ role_title = role["title"]
27
+
28
+ # Check for existing questions in question bank
29
+ bank_questions = []
30
+ if role_id:
31
+ cursor = db[QUESTIONS].find({"role_id": role_id}).limit(5)
32
+ async for q in cursor:
33
+ bank_questions.append(q["question"])
34
+
35
+ # Generate first question
36
+ question_data = await generate_interview_question(
37
+ skills=skills,
38
+ role_title=role_title,
39
+ difficulty="medium",
40
+ )
41
+
42
+ session_id = generate_id()
43
+ question_id = generate_id()
44
+
45
+ # Create session in MongoDB
46
+ session_doc = {
47
+ "session_id": session_id,
48
+ "user_id": user_id,
49
+ "role_id": role_id,
50
+ "role_title": role_title,
51
+ "status": "in_progress",
52
+ "question_count": 1,
53
+ "max_questions": MAX_QUESTIONS,
54
+ "current_difficulty": "medium",
55
+ "started_at": utc_now(),
56
+ }
57
+ await db[SESSIONS].insert_one(session_doc)
58
+
59
+ # Store session state in Redis
60
+ session_state = {
61
+ "user_id": user_id,
62
+ "role_title": role_title,
63
+ "skills": json.dumps(skills),
64
+ "question_count": 1,
65
+ "max_questions": MAX_QUESTIONS,
66
+ "current_difficulty": "medium",
67
+ "status": "in_progress",
68
+ }
69
+ await redis.hset(f"session:{session_id}", mapping=session_state)
70
+ await redis.expire(f"session:{session_id}", SESSION_TTL)
71
+
72
+ # Store question in Redis
73
+ q_data = {
74
+ "question_id": question_id,
75
+ "question": question_data.get("question", "Tell me about yourself."),
76
+ "difficulty": question_data.get("difficulty", "medium"),
77
+ "category": question_data.get("category", "general"),
78
+ }
79
+ await redis.hset(f"session:{session_id}:q:{question_id}", mapping=q_data)
80
+ await redis.rpush(f"session:{session_id}:questions", question_id)
81
+ await redis.expire(f"session:{session_id}:q:{question_id}", SESSION_TTL)
82
+ await redis.expire(f"session:{session_id}:questions", SESSION_TTL)
83
+
84
+ return {
85
+ "session_id": session_id,
86
+ "question": {
87
+ "question_id": question_id,
88
+ "question": q_data["question"],
89
+ "difficulty": q_data["difficulty"],
90
+ "question_number": 1,
91
+ "total_questions": MAX_QUESTIONS,
92
+ },
93
+ "message": "Interview started. Good luck!",
94
+ }
95
+
96
+
97
+ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
98
+ """Submit an answer and generate next question."""
99
+ db = get_db()
100
+ redis = get_redis()
101
+
102
+ # Get session state from Redis
103
+ session = await redis.hgetall(f"session:{session_id}")
104
+ if not session:
105
+ raise ValueError("Interview session not found or expired")
106
+
107
+ if session.get("status") != "in_progress":
108
+ raise ValueError("Interview is not in progress")
109
+
110
+ # Store answer in Redis
111
+ await redis.hset(f"session:{session_id}:a:{question_id}", mapping={
112
+ "question_id": question_id,
113
+ "answer": answer,
114
+ "submitted_at": utc_now(),
115
+ })
116
+ await redis.rpush(f"session:{session_id}:answers", question_id)
117
+ await redis.expire(f"session:{session_id}:a:{question_id}", SESSION_TTL)
118
+ await redis.expire(f"session:{session_id}:answers", SESSION_TTL)
119
+
120
+ question_count = int(session.get("question_count", 1))
121
+ max_questions = int(session.get("max_questions", MAX_QUESTIONS))
122
+
123
+ # Check if interview is complete
124
+ if question_count >= max_questions:
125
+ # Mark session as completed
126
+ await redis.hset(f"session:{session_id}", "status", "completed")
127
+ await db[SESSIONS].update_one(
128
+ {"session_id": session_id},
129
+ {"$set": {"status": "completed", "completed_at": utc_now()}},
130
+ )
131
+ return {
132
+ "session_id": session_id,
133
+ "next_question": None,
134
+ "is_complete": True,
135
+ "message": "Interview complete! Generating your report...",
136
+ }
137
+
138
+ # Adjust difficulty based on question count
139
+ difficulty = _adjust_difficulty(question_count, session.get("current_difficulty", "medium"))
140
+
141
+ # Get previous questions from Redis
142
+ question_ids = await redis.lrange(f"session:{session_id}:questions", 0, -1)
143
+ previous_questions = []
144
+ for qid in question_ids:
145
+ q = await redis.hgetall(f"session:{session_id}:q:{qid}")
146
+ if q:
147
+ previous_questions.append(q.get("question", ""))
148
+
149
+ # Get the current question text for context
150
+ current_q = await redis.hgetall(f"session:{session_id}:q:{question_id}")
151
+
152
+ skills = json.loads(session.get("skills", "[]"))
153
+ role_title = session.get("role_title", "Software Developer")
154
+
155
+ # Generate next question
156
+ next_question_data = await generate_interview_question(
157
+ skills=skills,
158
+ role_title=role_title,
159
+ previous_questions=previous_questions,
160
+ previous_answer=answer,
161
+ difficulty=difficulty,
162
+ )
163
+
164
+ new_question_id = generate_id()
165
+ new_count = question_count + 1
166
+
167
+ # Store new question in Redis
168
+ q_data = {
169
+ "question_id": new_question_id,
170
+ "question": next_question_data.get("question", "Can you elaborate further?"),
171
+ "difficulty": next_question_data.get("difficulty", difficulty),
172
+ "category": next_question_data.get("category", "general"),
173
+ }
174
+ await redis.hset(f"session:{session_id}:q:{new_question_id}", mapping=q_data)
175
+ await redis.rpush(f"session:{session_id}:questions", new_question_id)
176
+ await redis.expire(f"session:{session_id}:q:{new_question_id}", SESSION_TTL)
177
+
178
+ # Update session state
179
+ await redis.hset(f"session:{session_id}", mapping={
180
+ "question_count": str(new_count),
181
+ "current_difficulty": difficulty,
182
+ })
183
+
184
+ return {
185
+ "session_id": session_id,
186
+ "next_question": {
187
+ "question_id": new_question_id,
188
+ "question": q_data["question"],
189
+ "difficulty": q_data["difficulty"],
190
+ "question_number": new_count,
191
+ "total_questions": max_questions,
192
+ },
193
+ "is_complete": False,
194
+ "message": f"Question {new_count} of {max_questions}",
195
+ }
196
+
197
+
198
+ def _adjust_difficulty(question_number: int, current: str) -> str:
199
+ """Dynamically adjust difficulty based on progress."""
200
+ if question_number <= 3:
201
+ return "easy"
202
+ elif question_number <= 6:
203
+ return "medium"
204
+ else:
205
+ return "hard"
206
+
207
+
208
+ async def get_session_qa(session_id: str) -> list:
209
+ """Get all Q&A pairs from Redis for a session."""
210
+ redis = get_redis()
211
+
212
+ question_ids = await redis.lrange(f"session:{session_id}:questions", 0, -1)
213
+ qa_pairs = []
214
+
215
+ for qid in question_ids:
216
+ q = await redis.hgetall(f"session:{session_id}:q:{qid}")
217
+ a = await redis.hgetall(f"session:{session_id}:a:{qid}")
218
+ if q and a:
219
+ qa_pairs.append({
220
+ "question_id": qid,
221
+ "question": q.get("question", ""),
222
+ "answer": a.get("answer", ""),
223
+ "difficulty": q.get("difficulty", "medium"),
224
+ "category": q.get("category", "general"),
225
+ })
226
+
227
+ return qa_pairs
backend/services/resume_service.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import aiofiles
3
+ from database import get_db
4
+ from models.collections import RESUMES, SKILLS
5
+ from utils.helpers import utc_now, str_objectid
6
+ from utils.gemini import parse_resume_with_gemini
7
+ from config import get_settings
8
+
9
+ settings = get_settings()
10
+
11
+
12
+ async def upload_and_parse_resume(user_id: str, filename: str, file_content: bytes) -> dict:
13
+ """Upload resume file, parse with Gemini, extract skills."""
14
+ db = get_db()
15
+
16
+ # Save file locally
17
+ safe_filename = f"{user_id}_{filename}"
18
+ file_path = os.path.join(settings.UPLOAD_DIR, safe_filename)
19
+
20
+ async with aiofiles.open(file_path, "wb") as f:
21
+ await f.write(file_content)
22
+
23
+ # Read file text (for parsing)
24
+ resume_text = file_content.decode("utf-8", errors="ignore")
25
+
26
+ # Parse with Gemini
27
+ parsed_data = await parse_resume_with_gemini(resume_text)
28
+ skills = parsed_data.get("skills", [])
29
+
30
+ # Upsert resume document
31
+ resume_doc = {
32
+ "user_id": user_id,
33
+ "filename": safe_filename,
34
+ "original_filename": filename,
35
+ "file_path": file_path,
36
+ "parsed_text": parsed_data.get("experience_summary", ""),
37
+ "parsed_data": parsed_data,
38
+ "uploaded_at": utc_now(),
39
+ }
40
+
41
+ await db[RESUMES].update_one(
42
+ {"user_id": user_id},
43
+ {"$set": resume_doc},
44
+ upsert=True,
45
+ )
46
+
47
+ # Upsert skills
48
+ await db[SKILLS].update_one(
49
+ {"user_id": user_id},
50
+ {"$set": {
51
+ "user_id": user_id,
52
+ "skills": skills,
53
+ "updated_at": utc_now(),
54
+ }},
55
+ upsert=True,
56
+ )
57
+
58
+ result = await db[RESUMES].find_one({"user_id": user_id})
59
+ return {
60
+ "id": str(result["_id"]),
61
+ "user_id": user_id,
62
+ "filename": filename,
63
+ "parsed_text": resume_doc["parsed_text"],
64
+ "skills": skills,
65
+ "uploaded_at": resume_doc["uploaded_at"],
66
+ }
67
+
68
+
69
+ async def get_user_skills(user_id: str) -> list:
70
+ """Get extracted skills for a user."""
71
+ db = get_db()
72
+ skills_doc = await db[SKILLS].find_one({"user_id": user_id})
73
+ if skills_doc:
74
+ return skills_doc.get("skills", [])
75
+ return []
backend/utils/__init__.py ADDED
File without changes
backend/utils/gemini.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from google import genai
2
+ from config import get_settings
3
+
4
+ settings = get_settings()
5
+
6
+ client = genai.Client(api_key=settings.GEMINI_API_KEY)
7
+
8
+
9
+ async def call_gemini(prompt: str, system_instruction: str = None) -> str:
10
+ """Call Gemini API with a prompt and optional system instruction."""
11
+ config = {}
12
+ if system_instruction:
13
+ config["system_instruction"] = system_instruction
14
+
15
+ response = client.models.generate_content(
16
+ model=settings.GEMINI_MODEL,
17
+ contents=prompt,
18
+ config=config if config else None,
19
+ )
20
+ return response.text
21
+
22
+
23
+ async def parse_resume_with_gemini(resume_text: str) -> dict:
24
+ """Parse resume text and extract structured data using Gemini."""
25
+ prompt = f"""Analyze the following resume and extract structured information.
26
+ Return a JSON object with these fields:
27
+ - "skills": list of technical and soft skills
28
+ - "experience_summary": brief summary of work experience
29
+ - "education": list of educational qualifications
30
+ - "projects": list of notable projects
31
+
32
+ Resume text:
33
+ ---
34
+ {resume_text}
35
+ ---
36
+
37
+ Return ONLY valid JSON, no markdown formatting."""
38
+
39
+ result = await call_gemini(prompt)
40
+ # Clean up markdown code blocks if present
41
+ result = result.strip()
42
+ if result.startswith("```"):
43
+ result = result.split("\n", 1)[1]
44
+ if result.endswith("```"):
45
+ result = result.rsplit("```", 1)[0]
46
+ result = result.strip()
47
+
48
+ import json
49
+ try:
50
+ return json.loads(result)
51
+ except json.JSONDecodeError:
52
+ return {"skills": [], "experience_summary": result, "education": [], "projects": []}
53
+
54
+
55
+ async def generate_interview_question(
56
+ skills: list,
57
+ role_title: str,
58
+ previous_questions: list = None,
59
+ previous_answer: str = None,
60
+ difficulty: str = "medium",
61
+ ) -> dict:
62
+ """Generate an interview question using Gemini."""
63
+ context = f"Role: {role_title}\nCandidate Skills: {', '.join(skills)}\nDifficulty: {difficulty}"
64
+
65
+ if previous_questions:
66
+ context += f"\n\nPrevious questions asked (do NOT repeat these):\n"
67
+ for i, q in enumerate(previous_questions, 1):
68
+ context += f"{i}. {q}\n"
69
+
70
+ if previous_answer:
71
+ context += f"\nCandidate's last answer: {previous_answer}"
72
+ context += "\nGenerate a follow-up question based on this answer to probe deeper."
73
+
74
+ prompt = f"""{context}
75
+
76
+ Generate ONE interview question for this candidate. The question should:
77
+ 1. Be relevant to the role and candidate's skills
78
+ 2. Match the {difficulty} difficulty level
79
+ 3. Be clear and specific
80
+ 4. Test practical knowledge
81
+
82
+ Return ONLY a JSON object with:
83
+ - "question": the interview question text
84
+ - "difficulty": "{difficulty}"
85
+ - "category": the skill category this tests
86
+
87
+ Return ONLY valid JSON, no markdown formatting."""
88
+
89
+ result = await call_gemini(prompt)
90
+ result = result.strip()
91
+ if result.startswith("```"):
92
+ result = result.split("\n", 1)[1]
93
+ if result.endswith("```"):
94
+ result = result.rsplit("```", 1)[0]
95
+ result = result.strip()
96
+
97
+ import json
98
+ try:
99
+ return json.loads(result)
100
+ except json.JSONDecodeError:
101
+ return {
102
+ "question": f"Tell me about your experience with {skills[0] if skills else 'software development'}.",
103
+ "difficulty": difficulty,
104
+ "category": "general",
105
+ }
106
+
107
+
108
+ async def evaluate_interview(questions_and_answers: list, role_title: str) -> dict:
109
+ """Batch evaluate all interview Q&A pairs using Gemini."""
110
+ qa_text = ""
111
+ for i, qa in enumerate(questions_and_answers, 1):
112
+ qa_text += f"\nQ{i}: {qa['question']}\nA{i}: {qa['answer']}\n"
113
+
114
+ prompt = f"""You are an expert technical interviewer evaluating a candidate for the role: {role_title}
115
+
116
+ Here are the interview questions and the candidate's answers:
117
+ {qa_text}
118
+
119
+ Evaluate the candidate and return a JSON object with:
120
+ - "overall_score": integer from 0-100
121
+ - "detailed_scores": list of objects, each with:
122
+ - "question": the question text
123
+ - "answer": the answer text
124
+ - "score": integer 0-100
125
+ - "feedback": specific feedback for this answer
126
+ - "strengths": list of 3-5 strength areas
127
+ - "weaknesses": list of 3-5 areas for improvement
128
+ - "recommendations": list of 3-5 actionable recommendations
129
+
130
+ Be fair but thorough. Return ONLY valid JSON, no markdown formatting."""
131
+
132
+ result = await call_gemini(prompt)
133
+ result = result.strip()
134
+ if result.startswith("```"):
135
+ result = result.split("\n", 1)[1]
136
+ if result.endswith("```"):
137
+ result = result.rsplit("```", 1)[0]
138
+ result = result.strip()
139
+
140
+ import json
141
+ try:
142
+ return json.loads(result)
143
+ except json.JSONDecodeError:
144
+ return {
145
+ "overall_score": 50,
146
+ "detailed_scores": [],
147
+ "strengths": ["Unable to evaluate"],
148
+ "weaknesses": ["Unable to evaluate"],
149
+ "recommendations": ["Please retry the interview"],
150
+ }
backend/utils/helpers.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from bson import ObjectId
2
+ from datetime import datetime, timezone
3
+ import uuid
4
+
5
+
6
+ def generate_id() -> str:
7
+ """Generate a unique string ID."""
8
+ return str(uuid.uuid4())
9
+
10
+
11
+ def utc_now() -> str:
12
+ """Get current UTC timestamp as ISO string."""
13
+ return datetime.now(timezone.utc).isoformat()
14
+
15
+
16
+ def str_objectid(doc: dict) -> dict:
17
+ """Convert MongoDB ObjectId to string in a document."""
18
+ if doc and "_id" in doc:
19
+ doc["id"] = str(doc["_id"])
20
+ del doc["_id"]
21
+ return doc
22
+
23
+
24
+ def str_objectids(docs: list) -> list:
25
+ """Convert MongoDB ObjectIds to strings in a list of documents."""
26
+ return [str_objectid(doc) for doc in docs]