Spaces:
Sleeping
Sleeping
Commit ·
1cff1e5
1
Parent(s): ae45104
v1.1
Browse files- .gitignore +10 -0
- backend/auth/__init__.py +0 -0
- backend/auth/jwt.py +53 -0
- backend/config.py +44 -0
- backend/database.py +59 -0
- backend/main.py +53 -0
- backend/models/__init__.py +0 -0
- backend/models/collections.py +11 -0
- backend/requirements.txt +13 -0
- backend/routers/__init__.py +0 -0
- backend/routers/admin.py +167 -0
- backend/routers/auth.py +29 -0
- backend/routers/interview.py +65 -0
- backend/routers/profile.py +61 -0
- backend/routers/reports.py +12 -0
- backend/routers/resume.py +36 -0
- backend/schemas/__init__.py +0 -0
- backend/schemas/admin.py +59 -0
- backend/schemas/analytics.py +30 -0
- backend/schemas/auth.py +19 -0
- backend/schemas/interview.py +51 -0
- backend/schemas/resume.py +14 -0
- backend/services/__init__.py +0 -0
- backend/services/admin_service.py +118 -0
- backend/services/analytics_service.py +118 -0
- backend/services/auth_service.py +67 -0
- backend/services/evaluation_service.py +83 -0
- backend/services/interview_service.py +227 -0
- backend/services/resume_service.py +75 -0
- backend/utils/__init__.py +0 -0
- backend/utils/gemini.py +150 -0
- backend/utils/helpers.py +26 -0
.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]
|