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.middleware.cors import CORSMiddleware
|
||||
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"}
|
||||
|
||||
# --- 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"])
|
||||
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)
|
||||
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
|
||||
boto3==1.34.0
|
||||
python-multipart==0.0.9
|
||||
PyMuPDF==1.23.26
|
||||
|
||||
@@ -4,9 +4,12 @@ from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
from sqlmodel import Session, select
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
from database import engine
|
||||
import models
|
||||
import storage
|
||||
from pdf_parser import parse_pdf_to_questions
|
||||
|
||||
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}")
|
||||
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
|
||||
celery_app.conf.beat_schedule = {
|
||||
'generate-study-plans-every-morning': {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
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 { usePathname } from "next/navigation"
|
||||
import { Logo } from "@/components/Logo"
|
||||
@@ -19,6 +19,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
<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/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/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")} />
|
||||
|
||||
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;
|
||||
total_time: number;
|
||||
difficulty: string;
|
||||
scheduled_date?: string;
|
||||
}
|
||||
|
||||
export default function CBTPage() {
|
||||
@@ -116,6 +117,11 @@ export default function CBTPage() {
|
||||
const isAttempted = !!attempt;
|
||||
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 (
|
||||
<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">
|
||||
@@ -123,7 +129,11 @@ export default function CBTPage() {
|
||||
<Badge variant="outline" className="bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20">
|
||||
{exam.type} Test
|
||||
</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">
|
||||
<Lock size={12} /> Premium
|
||||
</Badge>
|
||||
@@ -137,10 +147,17 @@ export default function CBTPage() {
|
||||
{exam.title}
|
||||
</h3>
|
||||
<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>
|
||||
{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
|
||||
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"
|
||||
|
||||
@@ -18,11 +18,16 @@ export function StudyPlanCard() {
|
||||
async function fetchPlan() {
|
||||
try {
|
||||
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()
|
||||
setTasks(data.tasks || [])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} catch (err: any) {
|
||||
if (err.message !== "silent_fallback") {
|
||||
console.error("Study plan fetch error:", err)
|
||||
}
|
||||
// Mock fallback if celery hasn't seeded yet
|
||||
setTasks([
|
||||
{ type: "Revision", description: "Revise Thermodynamics", duration_minutes: 15, status: "Pending" },
|
||||
|
||||
Reference in New Issue
Block a user