Feat: Complete CBT PDF upload, AI parsing, and MinIO storage integration
This commit is contained in:
@@ -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
85
backend/pdf_parser.py
Normal 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 []
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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': {
|
||||||
|
|||||||
@@ -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")} />
|
||||||
|
|||||||
213
src/app/admin/tests/page.tsx
Normal file
213
src/app/admin/tests/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user