Feat: Complete CBT PDF upload, AI parsing, and MinIO storage integration

This commit is contained in:
2026-07-07 15:56:44 +05:30
parent c5260eec2e
commit b6ebb9d9c3
8 changed files with 476 additions and 8 deletions

View File

@@ -1,4 +1,4 @@
from fastapi import FastAPI, Depends, HTTPException, status, Request from fastapi import FastAPI, Depends, HTTPException, status, Request, UploadFile, File, Form, BackgroundTasks
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -75,6 +75,13 @@ def verify_otp(phone_or_email: str, code: str, session: Session = Depends(get_se
return {"status": "authenticated", "token": "mock_jwt_session_token_string"} return {"status": "authenticated", "token": "mock_jwt_session_token_string"}
# --- CBT TEST ENVIRONMENT CONTENT DELIVERY ENDPOINTS --- # --- CBT TEST ENVIRONMENT CONTENT DELIVERY ENDPOINTS ---
@app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"])
def list_active_tests(exam_pattern: Optional[str] = None, session: Session = Depends(get_session)):
query = select(models.Test).where(models.Test.is_active == True)
if exam_pattern:
query = query.where(models.Test.exam_pattern == exam_pattern)
return session.exec(query).all()
@app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"]) @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]))): 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]))):
@@ -512,3 +519,69 @@ def get_user_attempts(user_id: UUID, session: Session = Depends(get_session)):
query = select(models.Attempt).where(models.Attempt.user_id == user_id) query = select(models.Attempt).where(models.Attempt.user_id == user_id)
return session.exec(query).all() return session.exec(query).all()
# --- ADMIN ROUTES ---
@app.get("/api/admin/tests", tags=["Admin"])
def get_admin_tests(session: Session = Depends(get_session)):
tests = session.exec(select(models.Test)).all()
return tests
@app.post("/api/admin/tests/upload", tags=["Admin"])
async def upload_admin_test(
file: UploadFile = File(...),
title: str = Form(...),
exam_pattern: str = Form(...),
time_limit: int = Form(...),
scheduled_date: Optional[datetime] = Form(None),
admin_id: UUID = Form(...),
session: Session = Depends(get_session)
):
import storage
import uuid
from worker import process_pdf_test
pdf_bytes = await file.read()
object_name = f"tests/{uuid.uuid4()}_{file.filename}"
url = storage.upload_file(pdf_bytes, object_name, content_type="application/pdf")
if not url:
raise HTTPException(status_code=500, detail="Failed to upload PDF to MinIO")
# Create inactive test
test_obj = models.Test(
title=title,
type="Mock",
exam_pattern=models.ExamEnum(exam_pattern),
sections={},
total_time=time_limit,
difficulty="Medium",
scheduled_date=scheduled_date,
is_active=False, # Inactive until processing finishes
created_by=admin_id
)
session.add(test_obj)
session.commit()
session.refresh(test_obj)
# Trigger async background worker
process_pdf_test.delay(
test_id_str=str(test_obj.id),
object_name=object_name,
admin_id_str=str(admin_id),
exam_pattern=exam_pattern
)
return {"status": "processing", "test_id": test_obj.id, "message": "Test is being processed by AI"}
@app.put("/api/admin/tests/{test_id}/toggle", tags=["Admin"])
def toggle_test_status(test_id: UUID, session: Session = Depends(get_session)):
test_obj = session.get(models.Test, test_id)
if not test_obj:
raise HTTPException(status_code=404, detail="Test not found")
test_obj.is_active = not test_obj.is_active
session.add(test_obj)
session.commit()
return {"status": "success", "is_active": test_obj.is_active}

85
backend/pdf_parser.py Normal file
View File

@@ -0,0 +1,85 @@
import fitz # PyMuPDF
import json
import logging
import asyncio
from typing import List, Dict, Any
import os
from openai import AsyncOpenAI
logger = logging.getLogger("pdf_parser")
client = AsyncOpenAI(
base_url=os.getenv("LLM_BASE_URL", "http://host.docker.internal:11434/v1"),
api_key=os.getenv("OPENAI_API_KEY", "ollama"),
)
async def parse_pdf_to_questions(pdf_bytes: bytes, exam_pattern: str) -> List[Dict[str, Any]]:
"""
Extracts text from a PDF and uses the LLM to structure it into a JSON array of questions.
"""
text = ""
try:
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
for page in doc:
text += page.get_text("text") + "\n\n"
except Exception as e:
logger.error(f"Failed to parse PDF with PyMuPDF: {e}")
return []
system_prompt = f"""
You are an AI expert at extracting {exam_pattern} test questions from messy PDF text.
The text contains complex Chemistry and Math formulas.
CRITICAL: You MUST format all formulas perfectly in LaTeX wrapping inline math in $ and block math in $$.
Extract the questions and return them STRICTLY as a JSON array of objects.
If your API requires a JSON object root, return {{"questions": [...]}}.
Schema per question:
{{
"subject": "Physics" | "Chemistry" | "Maths" | "Biology",
"topic": "String",
"subtopic": "String",
"difficulty": "Easy" | "Medium" | "Hard",
"type": "MCQ_Single" | "MCQ_Multiple" | "Numerical",
"question_text": "Full text of the question with LaTeX",
"options": {{"A": "Option A text", "B": "Option B text", "C": "Option C text", "D": "Option D text"}},
"correct_answer": {{"value": "A"}},
"solution_text": "Detailed step-by-step solution if available, else 'Solution not provided'",
"language": "English"
}}
"""
try:
truncated_text = text[:15000]
response = await client.chat.completions.create(
model=os.getenv("LLM_MODEL_NAME", "gemma"),
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": truncated_text}
],
response_format={ "type": "json_object" }
)
output = response.choices[0].message.content
if not output:
return []
if "```json" in output:
output = output.split("```json")[1].split("```")[0].strip()
elif "```" in output:
output = output.split("```")[1].split("```")[0].strip()
data = json.loads(output)
if isinstance(data, list):
return data
elif isinstance(data, dict) and "questions" in data:
return data["questions"]
elif isinstance(data, dict):
return [data]
else:
return []
except Exception as e:
logger.error(f"LLM PDF parsing failed: {e}")
return []

View File

@@ -12,3 +12,4 @@ openai==1.14.0
httpx==0.27.2 httpx==0.27.2
boto3==1.34.0 boto3==1.34.0
python-multipart==0.0.9 python-multipart==0.0.9
PyMuPDF==1.23.26

View File

@@ -4,9 +4,12 @@ from celery import Celery
from celery.schedules import crontab from celery.schedules import crontab
from sqlmodel import Session, select from sqlmodel import Session, select
import json import json
import asyncio
from database import engine from database import engine
import models import models
import storage
from pdf_parser import parse_pdf_to_questions
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
@@ -75,6 +78,76 @@ def generate_daily_study_plans(self):
print(f"Database error during study plan generation: {exc}") print(f"Database error during study plan generation: {exc}")
raise self.retry(exc=exc) raise self.retry(exc=exc)
@celery_app.task(name="process_pdf_test", bind=True, max_retries=1)
def process_pdf_test(self, test_id_str: str, object_name: str, admin_id_str: str, exam_pattern: str):
"""
Background task to download PDF from MinIO, extract text,
use LLM to generate questions, and attach them to a Test record.
"""
try:
print(f"Starting PDF processing for test {test_id_str}")
# 1. Download PDF from MinIO
pdf_bytes = storage.get_file(object_name)
if not pdf_bytes:
print("Failed to download PDF from MinIO")
return
# 2. Extract Questions using LLM (Async)
questions_data = asyncio.run(parse_pdf_to_questions(pdf_bytes, exam_pattern))
if not questions_data:
print("LLM parsing returned empty data")
return
import uuid
test_id = uuid.UUID(test_id_str)
admin_id = uuid.UUID(admin_id_str)
with Session(engine) as session:
test_obj = session.get(models.Test, test_id)
if not test_obj:
print("Test record not found in DB")
return
question_ids = []
for q_data in questions_data:
# Create a Question DB object
new_q = models.Question(
subject=q_data.get("subject", "Physics"),
topic=q_data.get("topic", "General"),
subtopic=q_data.get("subtopic", "General"),
difficulty=q_data.get("difficulty", "Medium"),
type=q_data.get("type", models.QuestionTypeEnum.MCQ_SINGLE),
question_text=q_data.get("question_text", ""),
options=q_data.get("options", {}),
correct_answer=q_data.get("correct_answer", {}),
solution_text=q_data.get("solution_text", ""),
language=models.MediumEnum.ENGLISH,
exam=models.ExamEnum(exam_pattern),
created_by=admin_id
)
session.add(new_q)
session.flush() # To get the ID
question_ids.append(str(new_q.id))
# Update Test record with the new sections
test_obj.sections = {
"Section 1": {
"questions": question_ids,
"time_limit": test_obj.total_time
}
}
# Automatically activate it when done processing
test_obj.is_active = True
session.commit()
print(f"Successfully processed PDF and created {len(question_ids)} questions for test {test_id_str}")
return f"Processed {len(question_ids)} questions"
except Exception as exc:
print(f"Error processing PDF test: {exc}")
# Mark test as failed if we had a status field, for now just log it
raise self.retry(exc=exc)
# Schedule the tasks using Celery Beat # Schedule the tasks using Celery Beat
celery_app.conf.beat_schedule = { celery_app.conf.beat_schedule = {
'generate-study-plans-every-morning': { 'generate-study-plans-every-morning': {

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { ReactNode } from "react" import { ReactNode } from "react"
import { LayoutDashboard, Users, Database, Terminal, Settings } from "lucide-react" import { LayoutDashboard, Users, Database, Terminal, Settings, FileText } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { Logo } from "@/components/Logo" import { Logo } from "@/components/Logo"
@@ -19,6 +19,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
</div> </div>
<nav className="flex-1 px-4 space-y-1.5 mt-6"> <nav className="flex-1 px-4 space-y-1.5 mt-6">
<NavItem href="/admin" icon={<LayoutDashboard size={18} />} label="Command Center" active={pathname === "/admin"} /> <NavItem href="/admin" icon={<LayoutDashboard size={18} />} label="Command Center" active={pathname === "/admin"} />
<NavItem href="/admin/tests" icon={<FileText size={18} />} label="Tests & DPPs" active={pathname.startsWith("/admin/tests")} />
<NavItem href="/admin/questions" icon={<Database size={18} />} label="Question Bank" active={pathname.startsWith("/admin/questions")} /> <NavItem href="/admin/questions" icon={<Database size={18} />} label="Question Bank" active={pathname.startsWith("/admin/questions")} />
<NavItem href="/admin/agents" icon={<Terminal size={18} />} label="AI Agents" active={pathname.startsWith("/admin/agents")} /> <NavItem href="/admin/agents" icon={<Terminal size={18} />} label="AI Agents" active={pathname.startsWith("/admin/agents")} />
<NavItem href="/admin/students" icon={<Users size={18} />} label="Students" active={pathname.startsWith("/admin/students")} /> <NavItem href="/admin/students" icon={<Users size={18} />} label="Students" active={pathname.startsWith("/admin/students")} />

View File

@@ -0,0 +1,213 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Switch } from "@/components/ui/switch"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Loader2, Upload, FileText, Clock, CalendarIcon } from "lucide-react"
import { format } from "date-fns"
import { useActiveUser } from "@/hooks/useActiveUser"
export default function AdminTestsPage() {
const [tests, setTests] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false)
const { userId } = useActiveUser()
const fileInputRef = useRef<HTMLInputElement>(null)
// Upload Form State
const [title, setTitle] = useState("")
const [examPattern, setExamPattern] = useState("JEE_Main")
const [timeLimit, setTimeLimit] = useState("180")
const [scheduledDate, setScheduledDate] = useState("")
const fetchTests = async () => {
try {
const res = await fetch("http://localhost:8000/api/admin/tests")
if (res.ok) {
const data = await res.json()
setTests(data)
}
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchTests()
}, [])
const handleToggle = async (testId: string) => {
try {
const res = await fetch(`http://localhost:8000/api/admin/tests/${testId}/toggle`, {
method: "PUT"
})
if (res.ok) {
fetchTests()
}
} catch (e) {
console.error(e)
}
}
const handleUpload = async (e: React.FormEvent) => {
e.preventDefault()
if (!fileInputRef.current?.files?.[0]) return
if (!userId) return
setUploading(true)
const formData = new FormData()
formData.append("file", fileInputRef.current.files[0])
formData.append("title", title)
formData.append("exam_pattern", examPattern)
formData.append("time_limit", timeLimit)
formData.append("admin_id", userId)
if (scheduledDate) {
formData.append("scheduled_date", new Date(scheduledDate).toISOString())
}
try {
const res = await fetch("http://localhost:8000/api/admin/tests/upload", {
method: "POST",
body: formData
})
if (res.ok) {
alert("Test uploaded and sent to AI for processing!")
setTitle("")
if (fileInputRef.current) fileInputRef.current.value = ""
fetchTests()
} else {
alert("Upload failed")
}
} catch (error) {
console.error(error)
alert("Upload failed")
} finally {
setUploading(false)
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Tests & DPPs</h1>
<p className="text-muted-foreground mt-2">Manage CBT examinations and daily practice sheets</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Upload Form */}
<Card className="lg:col-span-1 border-muted bg-card shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="w-5 h-5 text-primary" />
Upload New Test
</CardTitle>
<CardDescription>Upload a PDF containing questions. AI will automatically extract and format them.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleUpload} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Test Title</label>
<Input required value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. JEE Main Mock Test 1" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Exam Pattern</label>
<Select value={examPattern} onValueChange={setExamPattern}>
<SelectTrigger><SelectValue placeholder="Select Exam" /></SelectTrigger>
<SelectContent>
<SelectItem value="JEE_Main">JEE Main</SelectItem>
<SelectItem value="JEE_Advanced">JEE Advanced</SelectItem>
<SelectItem value="NEET">NEET</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Time Limit (mins)</label>
<Input required type="number" value={timeLimit} onChange={e => setTimeLimit(e.target.value)} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Scheduled Date (Optional)</label>
<Input type="datetime-local" value={scheduledDate} onChange={e => setScheduledDate(e.target.value)} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Test PDF File</label>
<Input required type="file" accept=".pdf" ref={fileInputRef} className="cursor-pointer" />
<p className="text-xs text-muted-foreground mt-1">Make sure it contains clear questions and options.</p>
</div>
<Button type="submit" className="w-full" disabled={uploading}>
{uploading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Upload className="w-4 h-4 mr-2" />}
{uploading ? "Processing PDF via AI..." : "Upload & Parse"}
</Button>
</form>
</CardContent>
</Card>
{/* Existing Tests List */}
<Card className="lg:col-span-2 border-muted bg-card shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-primary" />
Test Repository
</CardTitle>
<CardDescription>Manage visibility and schedule for parsed tests.</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex justify-center p-8"><Loader2 className="w-8 h-8 animate-spin text-muted-foreground" /></div>
) : tests.length === 0 ? (
<div className="text-center p-8 text-muted-foreground border border-dashed rounded-lg">
No tests uploaded yet.
</div>
) : (
<div className="space-y-4">
{tests.map(test => (
<div key={test.id} className="flex items-center justify-between p-4 border rounded-lg bg-background">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{test.title}</h3>
<Badge variant="outline">{test.exam_pattern.replace("_", " ")}</Badge>
<Badge variant={test.is_active ? "default" : "secondary"}>
{test.is_active ? "Active" : "Inactive / Processing"}
</Badge>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{test.total_time} mins
</div>
{test.scheduled_date && (
<div className="flex items-center gap-1">
<CalendarIcon className="w-3 h-3" />
{format(new Date(test.scheduled_date), "PP p")}
</div>
)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">{test.is_active ? "Published" : "Hidden"}</span>
<Switch checked={test.is_active} onCheckedChange={() => handleToggle(test.id)} />
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -15,6 +15,7 @@ interface TestData {
exam_pattern: string; exam_pattern: string;
total_time: number; total_time: number;
difficulty: string; difficulty: string;
scheduled_date?: string;
} }
export default function CBTPage() { export default function CBTPage() {
@@ -116,6 +117,11 @@ export default function CBTPage() {
const isAttempted = !!attempt; const isAttempted = !!attempt;
const isLocked = !hasAccessTo("weekly-test-series"); const isLocked = !hasAccessTo("weekly-test-series");
// Date logic
const now = new Date();
const scheduledTime = exam.scheduled_date ? new Date(exam.scheduled_date) : now;
const isUpcoming = scheduledTime > now;
return ( return (
<Card key={exam.id} className={`hover:border-[#00F0FF]/50 hover:shadow-[0_0_15px_-5px_#00F0FF] transition-all group ${isAttempted ? 'border-primary/20' : ''} ${isLocked ? 'opacity-80' : ''}`}> <Card key={exam.id} className={`hover:border-[#00F0FF]/50 hover:shadow-[0_0_15px_-5px_#00F0FF] transition-all group ${isAttempted ? 'border-primary/20' : ''} ${isLocked ? 'opacity-80' : ''}`}>
<CardContent className="p-6"> <CardContent className="p-6">
@@ -123,7 +129,11 @@ export default function CBTPage() {
<Badge variant="outline" className="bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20"> <Badge variant="outline" className="bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20">
{exam.type} Test {exam.type} Test
</Badge> </Badge>
{isLocked ? ( {isUpcoming ? (
<Badge variant="secondary" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">
Upcoming
</Badge>
) : isLocked ? (
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20 flex items-center gap-1"> <Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20 flex items-center gap-1">
<Lock size={12} /> Premium <Lock size={12} /> Premium
</Badge> </Badge>
@@ -137,10 +147,17 @@ export default function CBTPage() {
{exam.title} {exam.title}
</h3> </h3>
<div className="text-sm text-muted-foreground space-y-1 mb-6"> <div className="text-sm text-muted-foreground space-y-1 mb-6">
<div className="font-medium text-foreground">Available Now</div> <div className="font-medium text-foreground">
{isUpcoming ? `Available on ${formatDate(exam.scheduled_date!)}` : 'Available Now'}
</div>
<div>{exam.total_time} Min • {exam.difficulty} Difficulty</div> <div>{exam.total_time} Min • {exam.difficulty} Difficulty</div>
</div> </div>
{isLocked ? (
{isUpcoming ? (
<Button disabled className="w-full gap-2 rounded-xl bg-secondary text-muted-foreground font-bold">
<Clock size={16} /> Starts Soon
</Button>
) : isLocked ? (
<Button <Button
onClick={() => setShowUpgradeModal(true)} onClick={() => setShowUpgradeModal(true)}
className="w-full gap-2 rounded-xl bg-secondary text-muted-foreground border border-dashed border-border hover:border-[#00F0FF]/50 hover:text-foreground font-bold" className="w-full gap-2 rounded-xl bg-secondary text-muted-foreground border border-dashed border-border hover:border-[#00F0FF]/50 hover:text-foreground font-bold"

View File

@@ -18,11 +18,16 @@ export function StudyPlanCard() {
async function fetchPlan() { async function fetchPlan() {
try { try {
const res = await fetch(`/api/ai-coach/study-plan/today?user_id=${userId}`) const res = await fetch(`/api/ai-coach/study-plan/today?user_id=${userId}`)
if (!res.ok) throw new Error("No plan") if (!res.ok) {
// If no plan is generated yet, fallback to mock silently
throw new Error("silent_fallback")
}
const data = await res.json() const data = await res.json()
setTasks(data.tasks || []) setTasks(data.tasks || [])
} catch (err) { } catch (err: any) {
console.error(err) if (err.message !== "silent_fallback") {
console.error("Study plan fetch error:", err)
}
// Mock fallback if celery hasn't seeded yet // Mock fallback if celery hasn't seeded yet
setTasks([ setTasks([
{ type: "Revision", description: "Revise Thermodynamics", duration_minutes: 15, status: "Pending" }, { type: "Revision", description: "Revise Thermodynamics", duration_minutes: 15, status: "Pending" },