feat: complete pragativeer v2 frontend and backend, docker setup, and cbt engine

This commit is contained in:
2026-07-06 17:08:13 +05:30
parent ce01795aa9
commit 426b98ddab
66 changed files with 7949 additions and 363 deletions

12
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
__pycache__/
*.py[cod]
*$py.class
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

15
backend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

34
backend/ai_coach.py Normal file
View File

@@ -0,0 +1,34 @@
from datetime import datetime, timedelta
from typing import Tuple
def calculate_sm2(q: int, repetitions: int, ease_factor: float, previous_interval: int) -> Tuple[int, float]:
"""
Implements the standard SuperMemo-2 (SM-2) algorithm.
q: quality of the response (0-5)
5: perfect response
4: correct response after a hesitation
3: correct response caught with serious difficulty
2: incorrect response; where the correct one seemed easy to recall
1: incorrect response; the correct one remembered
0: complete blackout
"""
if q < 3:
# If the answer was incorrect, reset repetitions and schedule for immediate review (1 day)
return 1, ease_factor
# Calculate new Easiness Factor
new_ef = ease_factor + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))
if new_ef < 1.3:
new_ef = 1.3
# Calculate next interval (in days)
if repetitions == 0:
new_interval = 1
elif repetitions == 1:
new_interval = 3 # Calibrated for intense JEE/NEET daily sprint cycles
elif repetitions == 2:
new_interval = 7
else:
new_interval = round(previous_interval * new_ef)
return new_interval, new_ef

63
backend/analytics.py Normal file
View File

@@ -0,0 +1,63 @@
from sqlmodel import Session, select
from datetime import datetime, timedelta
from collections import Counter
from uuid import UUID
import models
def generate_parent_report(student_id: UUID, session: Session) -> dict:
"""
Aggregates granular performance data for a specific student
into a clean, simple, parent-friendly schema.
"""
# 1. Fetch Student Baseline Info
student = session.get(models.User, student_id)
if not student:
return {"error": "Student record not found."}
# 2. Compile Last 7 Days Time & Attempts Data
seven_days_ago = datetime.utcnow() - timedelta(days=7)
attempts = session.exec(
select(models.Attempt)
.where(models.Attempt.user_id == student_id)
.where(models.Attempt.submitted_at >= seven_days_ago)
).all()
total_time_spent_seconds = sum((a.time_taken or 0) for a in attempts)
total_time_spent_minutes = round(total_time_spent_seconds / 60, 1)
# 3. Calculate 7-Day Accuracy Trend Line Data
# Group accuracy by date strings for simple frontend parsing
trend_data = []
for i in range(6, -1, -1):
target_date = (datetime.utcnow() - timedelta(days=i)).date()
daily_attempts = [a for a in attempts if a.submitted_at.date() == target_date]
if daily_attempts:
avg_accuracy = sum(a.accuracy for a in daily_attempts) / len(daily_attempts)
trend_data.append({"date": target_date.strftime("%b %d"), "accuracy": round(avg_accuracy, 1)})
else:
trend_data.append({"date": target_date.strftime("%b %d"), "accuracy": 0.0})
# 4. Extract Top "Areas of Concern" (Active un-mastered mistakes)
active_mistakes = session.exec(
select(models.Mistake)
.where(models.Mistake.user_id == student_id)
.where(models.Mistake.is_mastered == False)
).all()
# Aggregate topic counts to find where they struggle most
topic_counter = Counter([m.topic for m in active_mistakes])
top_concerns = [
{"topic": topic, "pending_errors": count}
for topic, count in topic_counter.most_common(3)
]
return {
"student_name": student.name,
"streak_count": student.streak_count,
"weekly_minutes_spent": total_time_spent_minutes,
"total_tests_attempted_this_week": len(attempts),
"accuracy_trend": trend_data,
"areas_of_concern": top_concerns,
"last_active": student.last_active.strftime("%Y-%m-%d %H:%M:%S")
}

45
backend/billing.py Normal file
View File

@@ -0,0 +1,45 @@
import os
import hmac
import hashlib
from uuid import UUID
# In a live environment, these are provided by the Razorpay Dashboard.
# We use mock keys for the local sandbox.
RAZORPAY_KEY_ID = os.getenv("RAZORPAY_KEY_ID", "rzp_test_local_sandbox")
RAZORPAY_KEY_SECRET = os.getenv("RAZORPAY_KEY_SECRET", "local_secret_mock")
RAZORPAY_WEBHOOK_SECRET = os.getenv("RAZORPAY_WEBHOOK_SECRET", "local_webhook_secret")
def generate_mock_order(user_id: UUID, tier: str, amount_in_paise: int) -> dict:
"""
Simulates the Razorpay Orders API response for local testing.
Normally this would call: razorpay_client.order.create(data)
"""
import time
mock_order_id = f"order_{int(time.time())}_{str(user_id)[:8]}"
return {
"id": mock_order_id,
"entity": "order",
"amount": amount_in_paise,
"currency": "INR",
"status": "created",
"notes": {
"user_id": str(user_id),
"tier_requested": tier
}
}
def verify_webhook_signature(payload_body: str, signature_header: str) -> bool:
"""
Cryptographically verifies that the webhook actually came from Razorpay.
"""
if not signature_header:
return False
expected_signature = hmac.new(
key=RAZORPAY_WEBHOOK_SECRET.encode('utf-8'),
msg=payload_body.encode('utf-8'),
digestmod=hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_signature, signature_header)

BIN
backend/celerybeat-schedule Normal file

Binary file not shown.

17
backend/database.py Normal file
View File

@@ -0,0 +1,17 @@
import os
from sqlmodel import create_engine, SQLModel, Session
from typing import Generator
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod")
# Using the standard SQLModel engine config for PostgreSQL
engine = create_engine(DATABASE_URL, echo=True)
def init_db():
# Automatically generates tables based on the classes imported from models.py
import models
SQLModel.metadata.create_all(engine)
def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session

36
backend/growth.py Normal file
View File

@@ -0,0 +1,36 @@
from sqlmodel import Session, select
from uuid import UUID
import uuid
from datetime import timedelta
import models
def generate_referral_link(user_id: UUID) -> str:
"""Generates a unique shortcode for the user to share."""
# Using a simple deterministic slice for the local sandbox
shortcode = str(user_id).split("-")[0]
return f"https://pragativeer.com/join/{shortcode}"
def process_referral_reward(referrer_id: UUID, new_user_id: UUID, session: Session):
"""
Grants both users 7 days of AI Coach access for free as per PRD Section 3.2.
"""
referrer = session.get(models.User, referrer_id)
new_user = session.get(models.User, new_user_id)
if referrer and new_user:
# Upgrade both to AI Coach
referrer.tier = models.TierEnum.AI_COACH
new_user.tier = models.TierEnum.AI_COACH
# Add 7 days to their trial/subscription end dates
from datetime import datetime
now = datetime.utcnow()
referrer.subscription_end = (referrer.subscription_end or now) + timedelta(days=7)
new_user.trial_end = now + timedelta(days=7)
session.add(referrer)
session.add(new_user)
session.commit()
return True
return False

55
backend/llm.py Normal file
View File

@@ -0,0 +1,55 @@
import os
import json
from openai import AsyncOpenAI
from sqlmodel import Session, select
from fastapi.responses import StreamingResponse
from uuid import UUID
import models
# Point the standard AsyncOpenAI client to your local Ollama server
client = AsyncOpenAI(
base_url=os.getenv("LLM_BASE_URL", "http://host.docker.internal:11434/v1"),
api_key=os.getenv("OPENAI_API_KEY", "ollama"), # Required by the SDK, but Ollama ignores it
)
async def stream_tutor_response(user_id: UUID, user_message: str, session: Session):
# 1. Fetch Context: Get the student's active weak areas
active_mistakes = session.exec(
select(models.Mistake)
.where(models.Mistake.user_id == user_id)
.where(models.Mistake.is_mastered == False)
).all()
weak_topics = list(set([m.topic for m in active_mistakes]))
context_string = ", ".join(weak_topics) if weak_topics else "General Syllabus"
# 2. Construct the highly-specific System Prompt
system_prompt = f"""
You are PragatiVeer's elite AI Tutor for JEE & NEET aspirants.
Your goal is to guide students to the answer, not just give it to them.
STUDENT CONTEXT:
This student currently struggles with the following topics: {context_string}.
Keep this in mind and relate explanations back to foundational concepts in these areas if applicable.
RULES:
1. Respond in the student's preferred language (English or Hindi).
2. Use LaTeX wrapped in single $ for inline math and $$ for block equations.
3. Keep responses concise and focused on problem-solving steps.
"""
# 3. Call the LLM
response = await client.chat.completions.create(
model=os.getenv("LLM_MODEL_NAME", "gemma"),
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message}
],
stream=True,
)
# 4. Yield tokens as they arrive
async for chunk in response:
if chunk.choices[0].delta.content is not None:
yield chunk.choices[0].delta.content

490
backend/main.py Normal file
View File

@@ -0,0 +1,490 @@
from fastapi import FastAPI, Depends, HTTPException, status, Request
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from sqlmodel import Session, select
from contextlib import asynccontextmanager
from typing import List, Dict, Any, Optional
from uuid import UUID
from datetime import datetime, timedelta
from pydantic import BaseModel
from database import init_db, get_session
import models
from ai_coach import calculate_sm2
from analytics import generate_parent_report
from billing import generate_mock_order, verify_webhook_signature
from llm import stream_tutor_response
from analytics import generate_parent_report
from predictor import calculate_readiness_score
from growth import generate_referral_link, process_referral_reward
@asynccontextmanager
async def lifespan(app: FastAPI):
# Initialize database tables locally when the container boots up
init_db()
yield
app = FastAPI(
title="PragatiVeer Core Engine",
version="1.0.0",
lifespan=lifespan
)
# Enable immediate communication with your Next.js frontend local environment
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health", tags=["System"])
def health_check():
return {"status": "operational", "engine": "FastAPI Async", "environment": "local_dev"}
# --- ACCESS CONTROL DEPENDENCY ---
def require_tier(allowed_tiers: List[models.TierEnum]):
"""
Dependency to block unauthorized access to premium routes.
"""
def tier_checker(user_id: UUID, session: Session = Depends(get_session)):
user = session.get(models.User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found.")
if user.tier not in allowed_tiers:
raise HTTPException(
status_code=403,
detail=f"Access denied. Requires one of: {[t.value for t in allowed_tiers]}"
)
return user
return tier_checker
# --- BASELINE AUTHENTICATION MOCKS FOR FRONTEND UI VERIFICATION ---
@app.post("/api/auth/otp-request", tags=["Authentication"])
def request_otp(phone_or_email: str):
# Mocking execution loop for user onboarding flow
return {"message": "Verification code dispatched successfully", "debug_otp": "123456"}
@app.post("/api/auth/otp-verify", tags=["Authentication"])
def verify_otp(phone_or_email: str, code: str, session: Session = Depends(get_session)):
# Add your logic matching the registration & profile onboarding flow later
return {"status": "authenticated", "token": "mock_jwt_session_token_string"}
# --- CBT TEST ENVIRONMENT CONTENT DELIVERY ENDPOINTS ---
@app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"])
def get_test_by_id(test_id: UUID, user_id: UUID, session: Session = Depends(get_session), current_user = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))):
test = session.get(models.Test, test_id)
if not test:
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
return test
@app.get("/api/questions/{question_id}", response_model=models.Question, tags=["CBT Engine"])
def get_question_by_id(question_id: UUID, session: Session = Depends(get_session)):
question = session.get(models.Question, question_id)
if not question:
raise HTTPException(status_code=404, detail="Requested question not found.")
return question
@app.post("/api/attempts/submit", response_model=models.Attempt, tags=["CBT Engine"])
def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_session)):
user_id = payload.get("user_id")
source_type = payload.get("source_type")
source_id = payload.get("source_id")
client_answers = payload.get("answers", []) # List of dicts: {"question_id": "...", "selected_answer": "..."}
if not user_id or not source_id:
raise HTTPException(status_code=400, detail="Missing required deployment metadata parameters.")
# 0. Enforce Timer Integrity: Verify that the test window is still active
if source_type == "Test":
test = session.get(models.Test, source_id)
if test and test.scheduled_date:
# Removed strict time cutoff for development/testing
pass
# 1. Initialize structural baseline metrics
total_questions = len(client_answers)
correct_count = 0
processed_answers = []
# Extract pre-generated attempt ID or create a new one
attempt_id_str = payload.get("id")
import uuid
attempt_id = uuid.UUID(attempt_id_str) if attempt_id_str else uuid.uuid4()
# 2. Secure Server-Side Evaluation Loop
for item in client_answers:
q_id = item.get("question_id")
selected = item.get("selected_answer")
# Fetch actual answer key securely from PostgreSQL (Do not trust frontend state values)
question = session.get(models.Question, q_id)
if not question:
continue
# Check correctness against JSON database configurations
is_correct = question.correct_answer.get("correct") == selected
if is_correct:
correct_count += 1
processed_answers.append({
"question_id": str(q_id),
"selected_answer": selected,
"is_correct": is_correct,
"time_spent": item.get("time_spent", 0)
})
# 3. AI Coach Processing: If the answer is incorrect, extract it to the Mistake queue
if not is_correct:
# Check if this user has failed this specific question before to track execution loops
existing_mistake = session.exec(
select(models.Mistake).where(
models.Mistake.user_id == user_id,
models.Mistake.question_id == q_id
)
).first()
if existing_mistake:
# User failed it again: reset repetitions, assign low quality score (q=1)
interval, ef = calculate_sm2(q=1, repetitions=existing_mistake.review_count, ease_factor=2.5, previous_interval=1)
existing_mistake.review_count += 1
existing_mistake.next_review = datetime.utcnow() + timedelta(days=interval)
existing_mistake.consecutive_correct = 0
session.add(existing_mistake)
else:
# Brand new mistake: enter into the spaced repetition queue for the first time
new_mistake = models.Mistake(
user_id=user_id,
question_id=q_id,
attempt_id=attempt_id, # Linking back to original source file attempt mapping
error_type=models.ErrorTypeEnum.CONCEPTUAL, # Defaults to conceptual until AI tags it
topic=question.topic,
subtopic=question.subtopic,
next_review=datetime.utcnow() + timedelta(days=1), # Schedule for tomorrow review loop
review_count=1,
consecutive_correct=0,
is_mastered=False
)
session.add(new_mistake)
# 4. Save Core Attempt Records
accuracy = (correct_count / total_questions) * 100 if total_questions > 0 else 0.0
attempt_record = models.Attempt(
id=attempt_id,
user_id=user_id,
source_type=source_type,
source_id=source_id,
started_at=datetime.utcnow() - timedelta(seconds=payload.get("time_taken", 0)),
submitted_at=datetime.utcnow(),
time_taken=payload.get("time_taken", 0),
answers=processed_answers,
score=correct_count,
total_questions=total_questions,
accuracy=accuracy,
status="Submitted"
)
session.add(attempt_record)
session.commit()
session.refresh(attempt_record)
return attempt_record
# --- AI COACH SPАСED REPETITION ENDPOINTS ---
@app.get("/api/mistakes/today", tags=["AI Coach"])
def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session)):
# Retrieve all un-mastered mistakes for the user scheduled on or before today
now = datetime.utcnow()
statement = select(models.Mistake, models.Question).join(
models.Question, models.Mistake.question_id == models.Question.id
).where(
models.Mistake.user_id == user_id,
models.Mistake.is_mastered == False,
models.Mistake.next_review <= now
)
results = session.exec(statement).all()
response_data = []
for mistake, question in results:
response_data.append({
"mistake": mistake,
"question": question
})
return response_data
@app.post("/api/mistakes/review", tags=["AI Coach"])
def submit_mistake_review(payload: Dict[str, Any], session: Session = Depends(get_session)):
mistake_id = payload.get("mistake_id")
selected_answer = payload.get("selected_answer")
if not mistake_id or not selected_answer:
raise HTTPException(status_code=400, detail="Missing mistake_id or selected_answer")
mistake = session.get(models.Mistake, mistake_id)
if not mistake:
raise HTTPException(status_code=404, detail="Mistake not found.")
question = session.get(models.Question, mistake.question_id)
is_correct = question.correct_answer.get("correct") == selected_answer
# Calculate intervals
q_value = 4 if is_correct else 1
# Very basic interval calculation for demo
days_since_creation = (datetime.utcnow() - mistake.created_at).days
prev_interval = max(1, days_since_creation)
interval, ef = calculate_sm2(q=q_value, repetitions=mistake.review_count, ease_factor=2.5, previous_interval=prev_interval)
if is_correct:
mistake.consecutive_correct += 1
if mistake.consecutive_correct >= 3:
mistake.is_mastered = True
else:
mistake.consecutive_correct = 0
mistake.review_count += 1
mistake.next_review = datetime.utcnow() + timedelta(days=interval)
session.add(mistake)
session.commit()
session.refresh(mistake)
return {"mistake": mistake, "is_correct": is_correct, "correct_answer": question.correct_answer.get("correct")}
# --- PARENT SUBSYSTEM ENDPOINTS ---
@app.post("/api/parent/register", response_model=models.Parent, tags=["Parent Subsystem"])
def register_parent(parent_data: models.Parent, session: Session = Depends(get_session)):
# Persist parent baseline identity profile
session.add(parent_data)
session.commit()
session.refresh(parent_data)
return parent_data
@app.post("/api/parent/link-student", tags=["Parent Subsystem"])
def link_student_to_parent(parent_id: UUID, student_phone_or_email: str, session: Session = Depends(get_session)):
parent = session.get(models.Parent, parent_id)
if not parent:
raise HTTPException(status_code=404, detail="Parent account context not found.")
# Locate the target student account by phone or email criteria
student = session.exec(
select(models.User).where(
(models.User.phone == student_phone_or_email) |
(models.User.email == student_phone_or_email)
)
).first()
if not student:
raise HTTPException(status_code=404, detail="Target student profile could not be verified.")
# Append the student's UUID string to the parent's linked array
current_links = list(parent.linked_students or [])
if str(student.id) not in current_links:
current_links.append(str(student.id))
parent.linked_students = current_links
session.add(parent)
session.commit()
return {"message": "Student successfully linked to parent context.", "linked_student_id": student.id}
@app.get("/api/parent/{parent_id}/dashboard/{student_id}", tags=["Parent Subsystem"])
def get_parent_dashboard_metrics(parent_id: UUID, student_id: UUID, session: Session = Depends(get_session)):
parent = session.get(models.Parent, parent_id)
if not parent:
raise HTTPException(status_code=404, detail="Unauthorized parent profile access attempt.")
# Security check: Ensure this parent is explicitly authorized to view this student's data
if str(student_id) not in (parent.linked_students or []):
raise HTTPException(status_code=403, detail="Access denied to requested student dashboard data.")
report = generate_parent_report(student_id, session)
return report
# --- BILLING & SUBSCRIPTION ENDPOINTS ---
@app.post("/api/billing/create-order", tags=["Subscription Engine"])
def create_subscription_order(user_id: UUID, plan_tier: str, session: Session = Depends(get_session)):
user = session.get(models.User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found.")
# Match PRD pricing parameters (amounts in paise)
pricing_map = {
"Pro": 29900, # ₹299/mo
"Al_Coach": 59900 # ₹599/mo
}
amount = pricing_map.get(plan_tier)
if not amount:
raise HTTPException(status_code=400, detail="Invalid subscription tier requested.")
order = generate_mock_order(user_id, plan_tier, amount)
return {"order_id": order["id"], "amount": amount, "currency": "INR"}
@app.post("/api/billing/webhook/razorpay", tags=["Subscription Engine"])
async def razorpay_webhook_handler(request: Request, session: Session = Depends(get_session)):
"""
Receives server-to-server notifications when a payment succeeds.
"""
payload_bytes = await request.body()
payload_str = payload_bytes.decode('utf-8')
signature = request.headers.get("X-Razorpay-Signature", "")
# Security Check: Verify signature
if not verify_webhook_signature(payload_str, signature):
raise HTTPException(status_code=400, detail="Invalid webhook signature.")
import json
data = json.loads(payload_str)
# Process the successful payment event
if data.get("event") == "payment.captured":
notes = data["payload"]["payment"]["entity"]["notes"]
user_id = notes.get("user_id")
tier_requested = notes.get("tier_requested")
user = session.get(models.User, user_id)
if user and tier_requested:
# Upgrade the user's tier in the database
user.tier = models.TierEnum(tier_requested)
session.add(user)
# Log the subscription record
from datetime import timedelta
new_sub = models.Subscription(
user_id=user.id,
tier=models.TierEnum(tier_requested),
billing_cycle="Monthly",
start_date=datetime.utcnow(),
end_date=datetime.utcnow() + timedelta(days=30),
amount=data["payload"]["payment"]["entity"]["amount"],
payment_id=data["payload"]["payment"]["entity"]["id"],
status="Active"
)
session.add(new_sub)
session.commit()
return {"status": "Webhook processed successfully"}
# --- STUDY PLAN ENDPOINTS ---
from datetime import date
@app.get("/api/ai-coach/study-plan/today", tags=["AI Coach"])
def get_todays_study_plan(user_id: UUID, session: Session = Depends(get_session), current_user = Depends(require_tier([models.TierEnum.AI_COACH]))):
today = datetime.utcnow().date()
plan = session.exec(
select(models.StudyPlan)
.where(models.StudyPlan.user_id == user_id)
.where(models.StudyPlan.date == today)
).first()
if not plan:
raise HTTPException(status_code=404, detail="No study plan generated for today yet.")
return plan
class ChatRequest(BaseModel):
user_id: UUID
message: str
@app.post("/api/ai-coach/chat/stream", tags=["AI Coach"])
async def ai_tutor_chat_stream(request: ChatRequest, session: Session = Depends(get_session)):
"""
Streams the context-aware LLM response back to the client.
"""
user = session.get(models.User, request.user_id)
if not user:
raise HTTPException(status_code=404, detail="Student profile not found.")
if user.tier != models.TierEnum.AI_COACH:
raise HTTPException(status_code=403, detail="Access denied. Gated to AI_COACH tier.")
# Enforce Daily Limit (50 messages) as per PRD A-04
# (In a full production build, you'd increment a Redis counter here)
return StreamingResponse(
stream_tutor_response(request.user_id, request.message, session),
media_type="text/event-stream"
)
@app.get("/api/ai-coach/readiness/{user_id}", tags=["AI Coach"])
def get_exam_readiness(user_id: UUID, session: Session = Depends(get_session), current_user = Depends(require_tier([models.TierEnum.AI_COACH]))):
"""
Returns the dynamic 0-100 Exam Readiness Score and College Prediction.
"""
readiness_data = calculate_readiness_score(user_id, session)
if not readiness_data:
raise HTTPException(status_code=404, detail="Could not calculate readiness score.")
return readiness_data
@app.get("/api/growth/referral/{user_id}", tags=["Viral Growth"])
def get_referral_dashboard(user_id: UUID, session: Session = Depends(get_session)):
user = session.get(models.User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found.")
link = generate_referral_link(user_id)
return {
"referral_link": link,
"total_referrals": 0, # In production, query a referral tracking table
"rewards_earned_days": 0,
"share_message": f"I'm on a {user.streak_count}-day study streak on PragatiVeer! Use my link to get 7 days of the AI Coach for free: {link}"
}
# --- USER CRUD & TEST LISTING ---
@app.get("/api/users/{user_id}", response_model=models.User, tags=["Users"])
def get_user_profile(user_id: UUID, session: Session = Depends(get_session)):
user = session.get(models.User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
class UserUpdate(BaseModel):
name: Optional[str] = None
target_exam: Optional[models.ExamEnum] = None
student_class: Optional[models.ClassEnum] = None
@app.put("/api/users/{user_id}", response_model=models.User, tags=["Users"])
def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = Depends(get_session)):
user = session.get(models.User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if payload.name is not None:
user.name = payload.name
if payload.target_exam is not None:
user.target_exam = payload.target_exam
if payload.student_class is not None:
# Note: mapping alias 'class' in User class to 'student_class'
user.student_class = payload.student_class
session.add(user)
session.commit()
session.refresh(user)
return user
@app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"])
def list_tests(exam_pattern: Optional[models.ExamEnum] = None, session: Session = Depends(get_session)):
query = select(models.Test)
if exam_pattern is not None:
query = query.where(models.Test.exam_pattern == exam_pattern)
return session.exec(query).all()
@app.get("/api/users/{user_id}/attempts", tags=["Users"])
def get_user_attempts(user_id: UUID, session: Session = Depends(get_session)):
query = select(models.Attempt).where(models.Attempt.user_id == user_id)
return session.exec(query).all()

168
backend/models.py Normal file
View File

@@ -0,0 +1,168 @@
from datetime import datetime, date
from enum import Enum
from typing import List, Optional, Dict, Any
from uuid import UUID, uuid4
from sqlmodel import Field, SQLModel, Relationship, JSON
# --- ENUMS ---
class ClassEnum(str, Enum):
ELEVENTH = "11"
TWELFTH = "12"
DROPPER = "Dropper"
class ExamEnum(str, Enum):
JEE_MAIN = "JEE_Main"
JEE_ADVANCED = "JEE_Advanced"
NEET = "NEET"
class MediumEnum(str, Enum):
ENGLISH = "English"
HINDI = "Hindi"
BILINGUAL = "Bilingual"
class TierEnum(str, Enum):
FREE = "Free"
PRO = "Pro"
AI_COACH = "Al_Coach"
class QuestionTypeEnum(str, Enum):
MCQ_SINGLE = "MCQ_Single"
MCQ_MULTIPLE = "MCQ_Multiple"
NUMERICAL = "Numerical"
class SourceTypeEnum(str, Enum):
DPS = "DPS"
TEST = "Test"
MICROTEST = "MicroTest"
class ErrorTypeEnum(str, Enum):
CONCEPTUAL = "Conceptual"
CALCULATION = "Calculation"
READING = "Reading"
TIME_PRESSURE = "Time_Pressure"
# --- CORE DB MODELS ---
class User(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
email: Optional[str] = Field(default=None, unique=True)
phone: Optional[str] = Field(default=None, unique=True)
password_hash: str
name: str
student_class: ClassEnum = Field(alias="class")
target_exam: ExamEnum
medium: MediumEnum
school: Optional[str] = None
city: str
state: str
tier: TierEnum = Field(default=TierEnum.FREE)
trial_end: Optional[datetime] = None
subscription_end: Optional[datetime] = None
streak_count: int = Field(default=0)
longest_streak: int = Field(default=0)
created_at: datetime = Field(default_factory=datetime.utcnow)
last_active: datetime = Field(default_factory=datetime.utcnow)
class Parent(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
email: str
phone: str
password_hash: str
name: str
language: str = Field(default="English") # e.g., Hindi, English
linked_students: List[str] = Field(default=[], sa_type=JSON)
created_at: datetime = Field(default_factory=datetime.utcnow)
class Question(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
subject: str # Physics, Chemistry, Maths, Biology
topic: str
subtopic: str
difficulty: str # Easy, Medium, Hard
type: QuestionTypeEnum
question_text: str
question_text_hi: Optional[str] = None
options: Optional[Dict[str, Any]] = Field(default=None, sa_type=JSON)
options_hi: Optional[Dict[str, Any]] = Field(default=None, sa_type=JSON)
correct_answer: Dict[str, Any] = Field(sa_type=JSON)
solution_text: str
solution_text_hi: Optional[str] = None
language: MediumEnum
year: Optional[int] = None
exam: Optional[ExamEnum] = None
tags: Optional[str] = None
is_active: bool = Field(default=True)
created_by: UUID
verified_by: Optional[UUID] = None
class DPS(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
date: date
title: str
questions: List[str] = Field(default=[], sa_type=JSON) # Array of question UUID strings
difficulty: str
time_limit: int
is_active: bool = Field(default=True)
created_by: UUID
class Test(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
title: str
type: str # Weekly, Mock, PYQ
exam_pattern: ExamEnum
sections: Dict[str, Any] = Field(sa_type=JSON) # Array of {name, questions[], time_limit}
total_time: int
difficulty: str
scheduled_date: Optional[datetime] = None
is_active: bool = Field(default=True)
created_by: UUID
class Attempt(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="user.id")
source_type: SourceTypeEnum
source_id: UUID
started_at: datetime
submitted_at: Optional[datetime] = None
time_taken: Optional[int] = None # in seconds
answers: Optional[Dict[str, Any]] = Field(default=None, sa_type=JSON) # Array of {question_id, selected_answer, is_correct, time_spent}
score: int = Field(default=0)
total_questions: int
accuracy: float = Field(default=0.0)
status: str = Field(default="In_Progress") # In_Progress, Submitted, Timed_Out
class Mistake(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="user.id")
question_id: UUID = Field(foreign_key="question.id")
attempt_id: UUID = Field(foreign_key="attempt.id")
error_type: ErrorTypeEnum
topic: str
subtopic: str
next_review: datetime
review_count: int = Field(default=0)
consecutive_correct: int = Field(default=0)
is_mastered: bool = Field(default=False)
created_at: datetime = Field(default_factory=datetime.utcnow)
class StudyPlan(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="user.id")
date: date
tasks: Dict[str, Any] = Field(sa_type=JSON) # Array of {type, description, duration_minutes, status}
is_completed: bool = Field(default=False)
generated_at: datetime = Field(default_factory=datetime.utcnow)
feedback: Optional[str] = None
class Subscription(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="user.id")
tier: TierEnum
billing_cycle: str # Monthly, Quarterly, Annual
start_date: datetime
end_date: datetime
amount: int # Stored in Paise (INR)
payment_id: str # Razorpay order ID / payment ID
status: str # Active, Expired, Cancelled, Paused
auto_renew: bool = Field(default=True)
coupon_code: Optional[str] = None

76
backend/predictor.py Normal file
View File

@@ -0,0 +1,76 @@
from sqlmodel import Session, select
from uuid import UUID
import models
def calculate_readiness_score(user_id: UUID, session: Session) -> dict:
"""
Calculates the Exam Readiness Score (0-100) based on PRD A-05 parameters.
Weights: Accuracy (40%), Speed (20%), Syllabus Coverage (20%),
Consistency (10%), Mistake Recovery (10%).
"""
user = session.get(models.User, user_id)
if not user:
return {}
attempts = session.exec(
select(models.Attempt).where(models.Attempt.user_id == user_id)
).all()
if not attempts:
return {"score": 0, "status": "Not enough data"}
# 1. Accuracy (40% Weight)
avg_accuracy = sum(a.accuracy for a in attempts) / len(attempts)
w_accuracy = (avg_accuracy / 100) * 40
# 2. Speed (20% Weight) - Assuming benchmark is 120s per question
time_per_q = [ (a.time_taken / a.total_questions) for a in attempts if a.total_questions > 0 and a.time_taken ]
avg_time = sum(time_per_q) / len(time_per_q) if time_per_q else 120
speed_factor = min(1.0, 120 / max(avg_time, 1)) # Cap at 1.0
w_speed = speed_factor * 20
# 3. Consistency (10% Weight)
streak = min(user.streak_count, 30) # Max consistency score hit at 30 days
w_consistency = (streak / 30) * 10
# 4. Mistake Recovery (10% Weight)
mistakes = session.exec(select(models.Mistake).where(models.Mistake.user_id == user_id)).all()
mastered_count = sum(1 for m in mistakes if m.is_mastered)
total_mistakes = max(len(mistakes), 1)
w_recovery = (mastered_count / total_mistakes) * 10
# 5. Syllabus Coverage (20% Weight)
# For local sandbox, we will estimate based on total questions attempted
total_q_attempted = sum(a.total_questions for a in attempts)
coverage_factor = min(total_q_attempted / 500, 1.0) # Assume 500 questions = decent baseline coverage
w_coverage = coverage_factor * 20
# Total Calculation
total_score = round(w_accuracy + w_speed + w_consistency + w_recovery + w_coverage)
# Calculate College Prediction based on PRD A-07
prediction = "Keep Practicing"
target_type = "State Level"
if total_score >= 85:
prediction = "IIT Bombay / IIT Delhi within reach!"
target_type = "Reach"
elif total_score >= 70:
prediction = "NIT Trichy / NIT Surathkal on track."
target_type = "Target"
elif total_score >= 50:
prediction = "Top GFTIs / State Engineering Colleges."
target_type = "Safety"
return {
"score": total_score,
"breakdown": {
"accuracy": round(w_accuracy, 1),
"speed": round(w_speed, 1),
"consistency": round(w_consistency, 1),
"recovery": round(w_recovery, 1),
"coverage": round(w_coverage, 1)
},
"college_prediction": prediction,
"prediction_category": target_type
}

12
backend/requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
fastapi==0.110.0
uvicorn[standard]==0.28.0
sqlmodel==0.0.16
psycopg2-binary==2.9.9
redis==5.0.3
pydantic==2.6.4
pydantic-settings==2.2.1
bcrypt==4.1.2
pyjwt==2.8.0
celery==5.3.6
openai==1.14.0
httpx==0.27.2

317
backend/seed.py Normal file
View File

@@ -0,0 +1,317 @@
from sqlmodel import Session, create_engine, select
from uuid import uuid4, UUID
from datetime import datetime
import os
from database import engine, init_db
import models
def seed_local_database():
# Ensure tables are created
init_db()
with Session(engine) as session:
# Prevent double seeding in local testing environment
if session.exec(select(models.Question)).first() is not None:
print("Local environment database already contains data components. Dropping all and reseeding...")
import sqlmodel
sqlmodel.SQLModel.metadata.drop_all(engine)
init_db()
print("Seeding baseline content entities...")
# 0. Create the baseline student user (seeded target: JEE_MAIN)
student_id = UUID("b0e17378-864d-439e-8806-708dc48a891b")
student_user = models.User(
id=student_id,
phone="9999999999",
email="student@pragativeer.com",
password_hash="mock_hash",
name="Rahul Sharma",
student_class=models.ClassEnum.TWELFTH,
medium=models.MediumEnum.ENGLISH,
target_exam=models.ExamEnum.JEE_MAIN,
city="New Delhi",
state="Delhi",
tier=models.TierEnum.AI_COACH,
streak_count=5,
last_active=datetime.utcnow()
)
session.add(student_user)
admin_id = uuid4()
jee_test_id = UUID("b6faa295-3bf5-4de2-a587-d72b889629c4")
neet_test_id = UUID("e7c80521-825b-4c07-889a-ee646c05d762")
def generate_dummy_questions(subject: str, count: int, exam: models.ExamEnum, admin_id: UUID, start_idx: int = 1):
qs = []
for i in range(start_idx, count + start_idx):
q = models.Question(
id=uuid4(),
subject=subject,
topic=f"Generic Topic {i}",
subtopic=f"Subtopic {i}",
difficulty="Medium",
type=models.QuestionTypeEnum.MCQ_SINGLE,
question_text=f"This is a simulated {exam.value} {subject} question #{i} for mock exam scaling.",
question_text_hi=f"यह एक अनुरूपित {exam.value} {subject} प्रश्न #{i} है।",
options={"A": "Option A", "B": "Option B", "C": "Option C", "D": "Option D"},
options_hi={"A": "विकल्प A", "B": "विकल्प B", "C": "विकल्प C", "D": "विकल्प D"},
correct_answer={"correct": "A"},
solution_text="Simulated solution for the mock test.",
solution_text_hi="मॉक टेस्ट के लिए अनुरूपित समाधान।",
language=models.MediumEnum.BILINGUAL,
year=2024,
exam=exam,
created_by=admin_id
)
qs.append(q)
return qs
# --- JEE QUESTIONS ---
# Physics 1
jq1 = models.Question(
id=uuid4(),
subject="Physics",
topic="Electrostatics",
subtopic="Coulomb's Law",
difficulty="Medium",
type=models.QuestionTypeEnum.MCQ_SINGLE,
question_text="Two point charges placed at a distance $r$ in air exert a force $F$ on each other. At what distance will they exert the same force if placed in a medium of dielectric constant $K=4$?",
question_text_hi="वायु में $r$ दूरी पर स्थित दो बिंदु आवेश एक दूसरे पर $F$ बल लगाते हैं। यदि उन्हें $K=4$ परावैद्युतांक वाले माध्यम में रखा जाए, तो वे किस दूरी पर समान बल लगाएंगे?",
options={"A": "$r$", "B": "$r/2$", "C": "$2r$", "D": "$r/4$"},
options_hi={"A": "$r$", "B": "$r/2$", "C": "$2r$", "D": "$r/4$"},
correct_answer={"correct": "B"},
solution_text="Force in medium $F' = \\frac{F}{K}$. To maintain same force, $r' = \\frac{r}{\\sqrt{K}} = \\frac{r}{\\sqrt{4}} = \\frac{r}{2}$.",
solution_text_hi="माध्यम में बल $F' = \\frac{F}{K}$। समान बल बनाए रखने के लिए, $r' = \\frac{r}{\\sqrt{K}} = \\frac{r}{\\sqrt{4}} = \\frac{r}{2}$।",
language=models.MediumEnum.BILINGUAL,
year=2024,
exam=models.ExamEnum.JEE_MAIN,
created_by=admin_id
)
# Physics 2
jq2 = models.Question(
id=uuid4(),
subject="Physics",
topic="Mechanics",
subtopic="Rotational Motion",
difficulty="Hard",
type=models.QuestionTypeEnum.MCQ_SINGLE,
question_text="A solid cylinder of mass $M$ and radius $R$ is rolling without slipping down an inclined plane of angle $\\theta$. What is the minimum coefficient of static friction $\\mu_s$ required to prevent slipping?",
question_text_hi="द्रव्यमान $M$ और त्रिज्या $R$ का एक ठोस बेलन कोण $\\theta$ के एक झुके हुए तल पर बिना फिसले लुढ़क रहा है। फिसलने से रोकने के लिए आवश्यक न्यूनतम स्थैतिक घर्षण गुणांक $\\mu_s$ क्या है?",
options={"A": "$\\frac{1}{3} \\tan \\theta$", "B": "$\\frac{2}{3} \\tan \\theta$", "C": "$\\frac{1}{2} \\tan \\theta$", "D": "$\\tan \\theta$"},
options_hi={"A": "$\\frac{1}{3} \\tan \\theta$", "B": "$\\frac{2}{3} \\tan \\theta$", "C": "$\\frac{1}{2} \\tan \\theta$", "D": "$\\tan \\theta$"},
correct_answer={"correct": "A"},
solution_text="$a = \\frac{g \\sin \\theta}{1 + \\frac{k^2}{R^2}}$. For solid cylinder $k^2 = \\frac{R^2}{2}$. So $a = \\frac{2}{3} g \\sin \\theta$. Friction $f = Mg \\sin \\theta - Ma = \\frac{1}{3} Mg \\sin \\theta$. Since $f \\le \\mu_s Mg \\cos \\theta$, $\\mu_s \\ge \\frac{1}{3} \\tan \\theta$.",
solution_text_hi="$a = \\frac{g \\sin \\theta}{1 + \\frac{k^2}{R^2}}$। ठोस बेलन के लिए $k^2 = \\frac{R^2}{2}$। इसलिए $a = \\frac{2}{3} g \\sin \\theta$। घर्षण $f = Mg \\sin \\theta - Ma = \\frac{1}{3} Mg \\sin \\theta$। चूंकि $f \\le \\mu_s Mg \\cos \\theta$, $\\mu_s \\ge \\frac{1}{3} \\tan \\theta$ है।",
language=models.MediumEnum.BILINGUAL,
year=2023,
exam=models.ExamEnum.JEE_MAIN,
created_by=admin_id
)
# Chemistry 1
jq3 = models.Question(
id=uuid4(),
subject="Chemistry",
topic="Physical Chemistry",
subtopic="Chemical Kinetics",
difficulty="Medium",
type=models.QuestionTypeEnum.MCQ_SINGLE,
question_text="For a first-order reaction, the time required for $99\\%$ completion is $x$ times the time required for $90\\%$ completion. The value of $x$ is:",
question_text_hi="एक प्रथम-कोटि की अभिक्रिया के लिए, $99\\%$ पूर्ण होने में लगा समय $90\\%$ पूर्ण होने में लगे समय का $x$ गुना है। $x$ का मान है:",
options={"A": "$2$", "B": "$10$", "C": "$3$", "D": "$4$"},
options_hi={"A": "$2$", "B": "$10$", "C": "$3$", "D": "$4$"},
correct_answer={"correct": "A"},
solution_text="$t = \\frac{2.303}{k} \\log \\frac{100}{100-x}$. $t_{99} = \\frac{2.303}{k} \\log 100 = 2 \\times \\frac{2.303}{k}$. $t_{90} = \\frac{2.303}{k} \\log 10 = 1 \\times \\frac{2.303}{k}$. Therefore $x = 2$.",
solution_text_hi="$t = \\frac{2.303}{k} \\log \\frac{100}{100-x}$। $t_{99} = \\frac{2.303}{k} \\log 100 = 2 \\times \\frac{2.303}{k}$। $t_{90} = \\frac{2.303}{k} \\log 10 = 1 \\times \\frac{2.303}{k}$। इसलिए $x = 2$ है।",
language=models.MediumEnum.BILINGUAL,
year=2024,
exam=models.ExamEnum.JEE_MAIN,
created_by=admin_id
)
# Maths 1
jq4 = models.Question(
id=uuid4(),
subject="Mathematics",
topic="Calculus",
subtopic="Definite Integration",
difficulty="Hard",
type=models.QuestionTypeEnum.MCQ_SINGLE,
question_text="The value of the integral $\\int_{0}^{\\pi/2} \\frac{\\sin^3 x}{\\sin^3 x + \\cos^3 x} dx$ is:",
question_text_hi="समाकल $\\int_{0}^{\\pi/2} \\frac{\\sin^3 x}{\\sin^3 x + \\cos^3 x} dx$ का मान है:",
options={"A": "$\\pi/2$", "B": "$\\pi/4$", "C": "$0$", "D": "$\\pi/8$"},
options_hi={"A": "$\\pi/2$", "B": "$\\pi/4$", "C": "$0$", "D": "$\\pi/8$"},
correct_answer={"correct": "B"},
solution_text="Let $I = \\int_{0}^{\\pi/2} \\frac{\\sin^3 x}{\\sin^3 x + \\cos^3 x} dx$. Using $\\int_0^a f(x)dx = \\int_0^a f(a-x)dx$, we get $I = \\int_{0}^{\\pi/2} \\frac{\\cos^3 x}{\\cos^3 x + \\sin^3 x} dx$. Adding both, $2I = \\int_{0}^{\\pi/2} 1 dx = \\pi/2$. Thus $I = \\pi/4$.",
solution_text_hi="माना $I = \\int_{0}^{\\pi/2} \\frac{\\sin^3 x}{\\sin^3 x + \\cos^3 x} dx$। गुणधर्म $\\int_0^a f(x)dx = \\int_0^a f(a-x)dx$ का उपयोग करने पर, $I = \\int_{0}^{\\pi/2} \\frac{\\cos^3 x}{\\cos^3 x + \\sin^3 x} dx$। दोनों को जोड़ने पर, $2I = \\int_{0}^{\\pi/2} 1 dx = \\pi/2$। इसलिए $I = \\pi/4$ है।",
language=models.MediumEnum.BILINGUAL,
year=2023,
exam=models.ExamEnum.JEE_MAIN,
created_by=admin_id
)
jee_physics_dummies = generate_dummy_questions("Physics", 28, models.ExamEnum.JEE_MAIN, admin_id, start_idx=3)
jee_chem_dummies = generate_dummy_questions("Chemistry", 29, models.ExamEnum.JEE_MAIN, admin_id, start_idx=2)
jee_math_dummies = generate_dummy_questions("Mathematics", 29, models.ExamEnum.JEE_MAIN, admin_id, start_idx=2)
session.add_all([jq1, jq2, jq3, jq4])
session.add_all(jee_physics_dummies)
session.add_all(jee_chem_dummies)
session.add_all(jee_math_dummies)
# --- NEET QUESTIONS ---
# Biology 1
nq1 = models.Question(
id=uuid4(),
subject="Botany",
topic="Genetics",
subtopic="Translation",
difficulty="Easy",
type=models.QuestionTypeEnum.MCQ_SINGLE,
question_text="Which of the following codons codes for Methionine and also acts as an initiator codon?",
question_text_hi="निम्नलिखित में से कौन सा कोडॉन मेथियोनिन के लिए कोड करता है और एक प्रारंभक कोडॉन के रूप में भी कार्य करता है?",
options={"A": "AUG", "B": "GUG", "C": "UAA", "D": "UAG"},
options_hi={"A": "AUG", "B": "GUG", "C": "UAA", "D": "UAG"},
correct_answer={"correct": "A"},
solution_text="AUG is the starting codon (initiator codon) in eukaryotes and prokaryotes and also codes for the amino acid methionine.",
solution_text_hi="AUG यूकेरियोट्स और प्रोकैरियोट्स में प्रारंभिक कोडॉन (प्रारंभक कोडॉन) है और अमीनो एसिड मेथियोनिन के लिए भी कोड करता है।",
language=models.MediumEnum.BILINGUAL,
year=2024,
exam=models.ExamEnum.NEET,
created_by=admin_id
)
# Physics 1 (NEET)
nq2 = models.Question(
id=uuid4(),
subject="Physics",
topic="Kinematics",
subtopic="Motion under Gravity",
difficulty="Medium",
type=models.QuestionTypeEnum.MCQ_SINGLE,
question_text="A ball is thrown vertically upward with a velocity of $20\\text{ m/s}$ from the top of a tower. If the height of the tower is $25\\text{ m}$, the time taken by the ball to reach the ground is (take $g = 10\\text{ m/s}^2$):",
question_text_hi="एक मीनार के शीर्ष से एक गेंद को $20\\text{ मी/से}$ के वेग से लंबवत ऊपर की ओर फेंका जाता है। यदि मीनार की ऊंचाई $25\\text{ मीटर}$ है, तो गेंद को जमीन पर पहुंचने में लगा समय है ($g = 10\\text{ मी/से}^2$ लें):",
options={"A": "$5\\text{ s}$", "B": "$3\\text{ s}$", "C": "$4\\text{ s}$", "D": "$2\\text{ s}$"},
options_hi={"A": "$5\\text{ सेकंड}$", "B": "$3\\text{ सेकंड}$", "C": "$4\\text{ सेकंड}$", "D": "$2\\text{ सेकंड}$"},
correct_answer={"correct": "A"},
solution_text="Using $s = ut + \\frac{1}{2}at^2$. Taking upward direction positive: $-25 = 20t - \\frac{1}{2}(10)t^2 \\implies 5t^2 - 20t - 25 = 0 \\implies t^2 - 4t - 5 = 0 \\implies (t-5)(t+1) = 0$. Since time cannot be negative, $t = 5\\text{ s}$.",
solution_text_hi="$s = ut + \\frac{1}{2}at^2$ का उपयोग करने पर। ऊपर की दिशा को धनात्मक मानने पर: $-25 = 20t - \\frac{1}{2}(10)t^2 \\implies 5t^2 - 20t - 25 = 0 \\implies t^2 - 4t - 5 = 0 \\implies (t-5)(t+1) = 0$। चूंकि समय ऋणात्मक नहीं हो सकता, $t = 5$ सेकंड है।",
language=models.MediumEnum.BILINGUAL,
year=2023,
exam=models.ExamEnum.NEET,
created_by=admin_id
)
# Chemistry 1 (NEET)
nq3 = models.Question(
id=uuid4(),
subject="Chemistry",
topic="Periodic Table",
subtopic="Ionic Radii",
difficulty="Medium",
type=models.QuestionTypeEnum.MCQ_SINGLE,
question_text="Which of the following represents the correct order of increasing ionic radii for isoelectronic species?",
question_text_hi="समइलेक्ट्रॉनिक प्रजातियों के लिए बढ़ती हुई आयनिक त्रिज्या का सही क्रम निम्नलिखित में से कौन सा दर्शाता है?",
options={"A": "$\\text{O}^{2-} < \\text{F}^- < \\text{Na}^+$", "B": "$\\text{Na}^+ < \\text{F}^- < \\text{O}^{2-}$", "C": "$\\text{F}^- < \\text{Na}^+ < \\text{O}^{2-}$", "D": "$\\text{O}^{2-} < \\text{Na}^+ < \\text{F}^-$"},
options_hi={"A": "$\\text{O}^{2-} < \\text{F}^- < \\text{Na}^+$", "B": "$\\text{Na}^+ < \\text{F}^- < \\text{O}^{2-}$", "C": "$\\text{F}^- < \\text{Na}^+ < \\text{O}^{2-}$", "D": "$\\text{O}^{2-} < \\text{Na}^+ < \\text{F}^-$"},
correct_answer={"correct": "B"},
solution_text="For isoelectronic species, ionic radii decrease with increase in atomic number (or nuclear charge). Nuclear charge: $\\text{Na}^+ (11) > \\text{F}^- (9) > \\text{O}^{2-} (8)$. Therefore, the correct order is $\\text{Na}^+ < \\text{F}^- < \\text{O}^{2-}$.",
solution_text_hi="समइलेक्ट्रॉनिक प्रजातियों के लिए, परमाणु संख्या (या नाभिकीय आवेश) में वृद्धि के साथ आयनिक त्रिज्या कम होती जाती है। नाभिकीय आवेश: $\\text{Na}^+ (11) > \\text{F}^- (9) > \\text{O}^{2-} (8)$। इसलिए, सही क्रम $\\text{Na}^+ < \\text{F}^- < \\text{O}^{2-}$ है।",
language=models.MediumEnum.BILINGUAL,
year=2024,
exam=models.ExamEnum.NEET,
created_by=admin_id
)
neet_physics_dummies = generate_dummy_questions("Physics", 49, models.ExamEnum.NEET, admin_id, start_idx=2)
neet_chem_dummies = generate_dummy_questions("Chemistry", 49, models.ExamEnum.NEET, admin_id, start_idx=2)
neet_botany_dummies = generate_dummy_questions("Botany", 49, models.ExamEnum.NEET, admin_id, start_idx=2)
neet_zoology_dummies = generate_dummy_questions("Zoology", 50, models.ExamEnum.NEET, admin_id, start_idx=1)
session.add_all([nq1, nq2, nq3])
session.add_all(neet_physics_dummies)
session.add_all(neet_chem_dummies)
session.add_all(neet_botany_dummies)
session.add_all(neet_zoology_dummies)
# --- TESTS CREATION ---
# 1. JEE Mock Test
jee_test = models.Test(
id=jee_test_id,
title="JEE Main Full Mock Test #1 (2025)",
type="Mock",
exam_pattern=models.ExamEnum.JEE_MAIN,
sections={
"Physics": {
"name": "Physics",
"questions": [str(jq1.id), str(jq2.id)] + [str(q.id) for q in jee_physics_dummies],
"time_limit": 60
},
"Chemistry": {
"name": "Chemistry",
"questions": [str(jq3.id)] + [str(q.id) for q in jee_chem_dummies],
"time_limit": 60
},
"Mathematics": {
"name": "Mathematics",
"questions": [str(jq4.id)] + [str(q.id) for q in jee_math_dummies],
"time_limit": 60
}
},
total_time=180,
difficulty="Mixed",
scheduled_date=datetime.utcnow(),
created_by=admin_id
)
session.add(jee_test)
# 2. NEET Mock Test
neet_test = models.Test(
id=neet_test_id,
title="NEET UG Full Mock Test #1 (2025)",
type="Mock",
exam_pattern=models.ExamEnum.NEET,
sections={
"Physics": {
"name": "Physics",
"questions": [str(nq2.id)] + [str(q.id) for q in neet_physics_dummies],
"time_limit": 45
},
"Chemistry": {
"name": "Chemistry",
"questions": [str(nq3.id)] + [str(q.id) for q in neet_chem_dummies],
"time_limit": 45
},
"Botany": {
"name": "Botany",
"questions": [str(nq1.id)] + [str(q.id) for q in neet_botany_dummies],
"time_limit": 45
},
"Zoology": {
"name": "Zoology",
"questions": [str(q.id) for q in neet_zoology_dummies],
"time_limit": 45
}
},
total_time=180,
difficulty="Mixed",
scheduled_date=datetime.utcnow(),
created_by=admin_id
)
session.add(neet_test)
session.commit()
print("\n=== LOCAL SEED COMPLETED SUCCESSFULLY ===")
print(f"JEE Test ID: {jee_test.id}")
print(f"NEET Test ID: {neet_test.id}\n")
if __name__ == "__main__":
seed_local_database()

84
backend/worker.py Normal file
View File

@@ -0,0 +1,84 @@
import os
from datetime import datetime, timedelta
from celery import Celery
from celery.schedules import crontab
from sqlmodel import Session, select
import json
from database import engine
import models
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
# Initialize Celery connected to our local Redis broker
celery_app = Celery(
"pragativeer_tasks",
broker=REDIS_URL,
backend=REDIS_URL
)
# Configure timezone to IST for accurate 5:00 AM scheduling
celery_app.conf.update(
timezone='Asia/Kolkata',
enable_utc=True,
)
@celery_app.task(name="generate_daily_study_plans", bind=True, default_retry_delay=60, max_retries=3)
def generate_daily_study_plans(self):
"""
Runs daily to evaluate students' weak areas and generate
a personalized AI study plan for the day.
"""
try:
with Session(engine) as session:
# Fetch all active users on the AI Coach tier
ai_students = session.exec(
select(models.User).where(models.User.tier == models.TierEnum.AI_COACH)
).all()
today = datetime.utcnow().date()
plans_created = 0
for student in ai_students:
# 1. Fetch pending spaced repetition mistakes for today
pending_mistakes = session.exec(
select(models.Mistake)
.where(models.Mistake.user_id == student.id)
.where(models.Mistake.is_mastered == False)
.where(models.Mistake.next_review <= datetime.utcnow())
).all()
# 2. Extract weak topics (for revision recommendation)
topics = [m.topic for m in pending_mistakes]
most_weak_topic = max(set(topics), key=topics.count) if topics else "Mixed Syllabus"
# 3. Construct the time-budgeted dynamic task list
tasks = [
{"type": "Revision", "description": f"Revise {most_weak_topic}", "duration_minutes": 15, "status": "Pending"},
{"type": "Mistake_Review", "description": "Daily Mistake Review Session", "duration_minutes": 10, "status": "Pending"},
{"type": "DPS", "description": f"Attempt {most_weak_topic} Practice Sheet", "duration_minutes": 20, "status": "Pending"}
]
# 4. Save to Database
new_plan = models.StudyPlan(
user_id=student.id,
date=today,
tasks=tasks,
is_completed=False
)
session.add(new_plan)
plans_created += 1
session.commit()
return f"Successfully generated {plans_created} daily study plans."
except Exception as exc:
print(f"Database error during study plan generation: {exc}")
raise self.retry(exc=exc)
# Schedule the tasks using Celery Beat
celery_app.conf.beat_schedule = {
'generate-study-plans-every-morning': {
'task': 'generate_daily_study_plans',
'schedule': crontab(hour=5, minute=0), # 5:00 AM IST
},
}