Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions backend/app/api/routes/quizzes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import json
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, status
from pydantic import BaseModel
from datetime import datetime

from app.models.quiz import (
QuizResponse,
GenerateQuizRequest,
SubmitQuizRequest,
QuizQuestionResponse,
QuizCreate,
QuizQuestionCreate
)
from app.services.supabase_client import get_supabase_client
from app.services.llm_factory import create_llm
from app.api.routes.videos import validate_uuid

router = APIRouter(prefix="/quizzes", tags=["quizzes"])

@router.get("", response_model=List[QuizResponse])
async def list_quizzes(user_id: str = Query(..., description="UUID of the user")):
user_uuid = validate_uuid(user_id, "user_id")
client = get_supabase_client()

result = (
client.table("quizzes")
.select("*")
.eq("user_id", str(user_uuid))
.order("created_at", desc=True)
.execute()
)
return result.data

@router.get("/{quiz_id}", response_model=QuizResponse)
async def get_quiz(quiz_id: str, user_id: str = Query(..., description="UUID of the user")):
quiz_uuid = validate_uuid(quiz_id, "quiz_id")
user_uuid = validate_uuid(user_id, "user_id")
client = get_supabase_client()

# Get quiz
quiz_res = client.table("quizzes").select("*").eq("id", str(quiz_uuid)).eq("user_id", str(user_uuid)).execute()
if not quiz_res.data:
raise HTTPException(status_code=404, detail="Quiz not found")

quiz = quiz_res.data[0]

# Get questions
questions_res = client.table("quiz_questions").select("*").eq("quiz_id", str(quiz_uuid)).order("created_at", desc=False).execute()

quiz["questions"] = questions_res.data
return quiz

@router.post("/generate", response_model=QuizResponse, status_code=status.HTTP_201_CREATED)
async def generate_quiz(
req: GenerateQuizRequest,
user_id: str = Query(..., description="UUID of the user")
):
user_uuid = validate_uuid(user_id, "user_id")
client = get_supabase_client()
llm = create_llm(role="planner", temperature=0.7)

difficulty_instruction = ""
if req.difficulty.lower() == "easy":
difficulty_instruction = "The difficulty is easy. Ask basic questions suitable for a beginner."
elif req.difficulty.lower() == "hard":
difficulty_instruction = "The difficulty is hard. Ask advanced, complex questions suitable for an expert."
else:
difficulty_instruction = "The difficulty is medium. Ask moderately challenging questions suitable for a professional."

prompt = f"""
You are an expert educator. Create a {req.count}-question multiple choice quiz on the following topic: '{req.topic}'.
{difficulty_instruction}
Return the output EXACTLY as a JSON array of objects.
Each object must have the following keys:
- 'question_text': string
- 'options': array of 4 string options
- 'correct_option_index': integer (0-3) representing the index of the correct option
- 'explanation': string explaining why the correct answer is correct

Do not include markdown formatting like ```json ... ```, just output the raw JSON array.
"""
try:
from langchain_core.messages import SystemMessage, HumanMessage
messages = [
SystemMessage(content="You generate valid JSON arrays of quiz questions."),
HumanMessage(content=prompt)
]
resp = llm.invoke(messages)
content = resp.content.strip()
if content.startswith("```json"):
content = content[7:]
if content.startswith("```"):
content = content[3:]
if content.endswith("```"):
content = content[:-3]

questions_data = json.loads(content)

if not questions_data or len(questions_data) == 0:
raise ValueError("No questions generated")

# Create Quiz
title = f"{req.topic} Quiz"
quiz_res = client.table("quizzes").insert({
"user_id": str(user_uuid),
"title": title,
"topic": req.topic,
"difficulty": req.difficulty,
"total_questions": len(questions_data)
}).execute()

if not quiz_res.data:
raise HTTPException(status_code=500, detail="Failed to create quiz in DB")

quiz = quiz_res.data[0]
quiz_uuid = quiz["id"]

# Create Questions
created_questions = []
for q in questions_data:
insert_res = client.table("quiz_questions").insert({
"quiz_id": str(quiz_uuid),
"question_text": q["question_text"],
"options": q["options"],
"correct_option_index": q["correct_option_index"],
"explanation": q.get("explanation", "")
}).execute()
if insert_res.data:
created_questions.append(insert_res.data[0])

quiz["questions"] = created_questions
return quiz

except Exception as e:
import logging
logging.error(f"Failed to generate quiz: {e}")
raise HTTPException(status_code=500, detail=f"AI generation failed: {str(e)}")

@router.post("/{quiz_id}/submit", response_model=QuizResponse)
async def submit_quiz(
quiz_id: str,
req: SubmitQuizRequest,
user_id: str = Query(..., description="UUID of the user")
):
quiz_uuid = validate_uuid(quiz_id, "quiz_id")
user_uuid = validate_uuid(user_id, "user_id")
client = get_supabase_client()

# Get questions
questions_res = client.table("quiz_questions").select("*").eq("quiz_id", str(quiz_uuid)).execute()
if not questions_res.data:
raise HTTPException(status_code=404, detail="Quiz questions not found")

questions = questions_res.data
score = 0

for q in questions:
question_id = str(q["id"])
if question_id in req.answers:
user_answer = req.answers[question_id]
is_correct = user_answer == q["correct_option_index"]
if is_correct:
score += 1

# Update user's answer
client.table("quiz_questions").update({"user_answer_index": user_answer}).eq("id", question_id).execute()

# Update quiz score
updated_quiz_res = client.table("quizzes").update({"score": score}).eq("id", str(quiz_uuid)).eq("user_id", str(user_uuid)).execute()
if not updated_quiz_res.data:
raise HTTPException(status_code=500, detail="Failed to update quiz score")

quiz = updated_quiz_res.data[0]

# Fetch updated questions
updated_questions_res = client.table("quiz_questions").select("*").eq("quiz_id", str(quiz_uuid)).order("created_at", desc=False).execute()
quiz["questions"] = updated_questions_res.data

return quiz

@router.delete("/{quiz_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_quiz(
quiz_id: str,
user_id: str = Query(..., description="UUID of the user")
):
quiz_uuid = validate_uuid(quiz_id, "quiz_id")
user_uuid = validate_uuid(user_id, "user_id")
client = get_supabase_client()

client.table("quizzes").delete().eq("id", str(quiz_uuid)).eq("user_id", str(user_uuid)).execute()
return None

@router.post("/{quiz_id}/retake", response_model=QuizResponse)
async def retake_quiz(
quiz_id: str,
user_id: str = Query(..., description="UUID of the user")
):
quiz_uuid = validate_uuid(quiz_id, "quiz_id")
user_uuid = validate_uuid(user_id, "user_id")
client = get_supabase_client()

# Check if quiz exists and belongs to user
quiz_res = client.table("quizzes").select("*").eq("id", str(quiz_uuid)).eq("user_id", str(user_uuid)).execute()
if not quiz_res.data:
raise HTTPException(status_code=404, detail="Quiz not found")

# Reset quiz score
updated_quiz_res = client.table("quizzes").update({"score": None}).eq("id", str(quiz_uuid)).eq("user_id", str(user_uuid)).execute()
if not updated_quiz_res.data:
raise HTTPException(status_code=500, detail="Failed to reset quiz score")

quiz = updated_quiz_res.data[0]

# Reset all questions' user_answer_index
client.table("quiz_questions").update({"user_answer_index": None}).eq("quiz_id", str(quiz_uuid)).execute()

# Fetch reset questions
updated_questions_res = client.table("quiz_questions").select("*").eq("quiz_id", str(quiz_uuid)).order("created_at", desc=False).execute()
quiz["questions"] = updated_questions_res.data

return quiz
3 changes: 2 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.api.routes import upload, videos, webhooks, flashcards
from app.api.routes import upload, videos, webhooks, flashcards, quizzes
from app.config import get_settings

# Get application settings
Expand Down Expand Up @@ -44,6 +44,7 @@
app.include_router(videos.router, prefix="/api")
app.include_router(webhooks.router, prefix="/api")
app.include_router(flashcards.router, prefix="/api")
app.include_router(quizzes.router, prefix="/api")


@app.get("/")
Expand Down
37 changes: 37 additions & 0 deletions backend/app/models/quiz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from uuid import UUID

class QuizQuestionCreate(BaseModel):
question_text: str
options: List[str]
correct_option_index: int
explanation: Optional[str] = None

class QuizQuestionResponse(QuizQuestionCreate):
id: UUID
quiz_id: UUID
user_answer_index: Optional[int] = None
created_at: datetime

class QuizCreate(BaseModel):
title: str
topic: str
difficulty: str
total_questions: int

class QuizResponse(QuizCreate):
id: UUID
user_id: UUID
score: Optional[int] = None
created_at: datetime
questions: Optional[List[QuizQuestionResponse]] = None

class GenerateQuizRequest(BaseModel):
topic: str
difficulty: str = Field(default="medium")
count: int = Field(default=5, ge=1, le=20)

class SubmitQuizRequest(BaseModel):
answers: dict[str, int] # Mapping of question_id (str) to user_answer_index (int)
21 changes: 18 additions & 3 deletions frontend/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ export default async function DashboardPage() {
{/* Generation Pipeline (appears after Generate is clicked) */}
<GenerationPipeline />

{/* Flashcards Quick Link */}
<div className="mt-12 w-full max-w-3xl mx-auto">
<Link href="/flashcards">
{/* Quick Links */}
<div className="mt-12 w-full max-w-3xl mx-auto space-y-4">
<Link href="/flashcards" className="block">
<div className="bg-white dark:bg-card border border-gray-200 dark:border-border rounded-xl p-6 hover:shadow-md transition-shadow flex items-center justify-between cursor-pointer group">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-full bg-[#FFDFDF] dark:bg-primary/20 flex items-center justify-center">
Expand All @@ -62,6 +62,21 @@ export default async function DashboardPage() {
<ArrowRight className="h-5 w-5 text-gray-400 group-hover:text-[#e8609a] transition-colors" />
</div>
</Link>

<Link href="/quizzes" className="block">
<div className="bg-white dark:bg-card border border-gray-200 dark:border-border rounded-xl p-6 hover:shadow-md transition-shadow flex items-center justify-between cursor-pointer group">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-full bg-[#FFDFDF] dark:bg-primary/20 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-6 w-6 text-[#e8609a] dark:text-primary"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-foreground">Take Quizzes</h3>
<p className="text-sm text-gray-500 dark:text-muted-foreground">Test your knowledge with AI-generated quizzes and track your score.</p>
</div>
</div>
<ArrowRight className="h-5 w-5 text-gray-400 group-hover:text-[#e8609a] transition-colors" />
</div>
</Link>
</div>
</div>
</main>
Expand Down
Loading
Loading