baveshraam's picture
FIX: SurrealDB 2.0 migration syntax and Frontend/CORS link
f871fed
"""
Quiz and Flashcard API Router
"""
from datetime import datetime
from typing import List, Literal, Optional
from fastapi import APIRouter, HTTPException
from fsrs import Rating
from loguru import logger
from pydantic import BaseModel, Field
from open_notebook.domain.quiz import (
Flashcard,
QuizQuestion,
QuizSession,
UserStudyStats,
)
from open_notebook.services.quiz_service import QuizGenerationService
router = APIRouter(prefix="/quiz", tags=["quiz"])
# ==================== Request/Response Models ====================
class QuizGenerateRequest(BaseModel):
notebook_id: str = Field(..., description="ID of the notebook to generate quiz from")
num_questions: int = Field(default=10, ge=1, le=50, description="Number of questions")
difficulty: Literal["easy", "medium", "hard", "mixed"] = Field(
default="mixed", description="Quiz difficulty"
)
source_ids: Optional[List[str]] = Field(
default=None, description="Specific source IDs to use (optional)"
)
model_id: Optional[str] = Field(
default=None, description="Model ID to use for generation"
)
class QuizQuestionResponse(BaseModel):
id: str
question: str
question_type: str
options: List[str]
difficulty: str
user_answer: Optional[int] = None
is_correct: Optional[bool] = None
# Only include after answering
correct_index: Optional[int] = None
explanation: Optional[str] = None
class QuizSessionResponse(BaseModel):
id: str
notebook_id: str
title: Optional[str]
question_count: int
correct_count: int
score: Optional[float]
difficulty: str
status: str
started_at: Optional[str]
completed_at: Optional[str]
created: str
class QuizSessionDetailResponse(QuizSessionResponse):
questions: List[QuizQuestionResponse]
class SubmitAnswerRequest(BaseModel):
question_id: str
answer: int = Field(..., ge=0, le=3, description="Index of selected answer (0-3)")
time_spent_seconds: Optional[int] = Field(
default=None, description="Time spent on question in seconds"
)
class SubmitAnswerResponse(BaseModel):
is_correct: bool
correct_index: int
explanation: str
session_progress: dict
# ==================== Flashcard Models ====================
class FlashcardCreateRequest(BaseModel):
notebook_id: str
front: str = Field(..., min_length=1, description="Front of card (question)")
back: str = Field(..., min_length=1, description="Back of card (answer)")
source_id: Optional[str] = None
tags: Optional[List[str]] = Field(default_factory=list)
class FlashcardGenerateRequest(BaseModel):
notebook_id: str
num_cards: int = Field(default=20, ge=1, le=100, description="Number of cards to generate")
source_ids: Optional[List[str]] = None
model_id: Optional[str] = None
class FlashcardResponse(BaseModel):
id: str
front: str
back: str
tags: List[str]
difficulty: float
state: int
due: Optional[str]
reps: int
created: str
class FlashcardReviewRequest(BaseModel):
rating: int = Field(
..., ge=1, le=4,
description="Review rating: 1=Again, 2=Hard, 3=Good, 4=Easy"
)
class FlashcardStatsResponse(BaseModel):
total: int
new: int
learning: int
review: int
due: int
# ==================== Study Stats Models ====================
class StudyStatsResponse(BaseModel):
user_id: str
current_streak: int
longest_streak: int
total_xp: int
level: int
badges: List[str]
total_quizzes_completed: int
total_flashcards_reviewed: int
total_correct_answers: int
xp_to_next_level: int
# ==================== Quiz Endpoints ====================
@router.post("/generate", response_model=QuizSessionResponse)
async def generate_quiz(request: QuizGenerateRequest):
"""Generate a new quiz from notebook content"""
try:
session = await QuizGenerationService.generate_quiz(
notebook_id=request.notebook_id,
num_questions=request.num_questions,
difficulty=request.difficulty,
source_ids=request.source_ids,
model_id=request.model_id
)
return QuizSessionResponse(
id=session.id,
notebook_id=session.notebook_id,
title=session.title,
question_count=session.question_count,
correct_count=session.correct_count,
score=session.score,
difficulty=session.difficulty,
status=session.status,
started_at=session.started_at.isoformat() if session.started_at else None,
completed_at=session.completed_at.isoformat() if session.completed_at else None,
created=session.created.isoformat() if session.created else ""
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error generating quiz: {str(e)}")
logger.exception(e) # This will log the full traceback
raise HTTPException(status_code=500, detail=f"Failed to generate quiz: {str(e)}")
@router.get("/sessions", response_model=List[QuizSessionResponse])
async def get_quiz_sessions(
notebook_id: str,
limit: int = 20
):
"""Get quiz sessions for a notebook"""
try:
sessions = await QuizSession.get_by_notebook(notebook_id, limit)
return [
QuizSessionResponse(
id=s.id,
notebook_id=s.notebook_id,
title=s.title,
question_count=s.question_count,
correct_count=s.correct_count,
score=s.score,
difficulty=s.difficulty,
status=s.status,
started_at=s.started_at.isoformat() if s.started_at else None,
completed_at=s.completed_at.isoformat() if s.completed_at else None,
created=s.created.isoformat() if s.created else ""
)
for s in sessions
]
except Exception as e:
logger.error(f"Error fetching quiz sessions: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to fetch quiz sessions")
@router.get("/sessions/{session_id}", response_model=QuizSessionDetailResponse)
async def get_quiz_session(session_id: str, show_answers: bool = False):
"""Get a quiz session with questions"""
try:
session = await QuizSession.get(session_id)
questions = await session.get_questions()
question_responses = []
for q in questions:
resp = QuizQuestionResponse(
id=q.id,
question=q.question,
question_type=q.question_type,
options=q.options,
difficulty=q.difficulty,
user_answer=q.user_answer,
is_correct=q.is_correct
)
# Include answers if already answered or show_answers is True
if q.user_answer is not None or show_answers or session.status == "completed":
resp.correct_index = q.correct_index
resp.explanation = q.explanation
question_responses.append(resp)
return QuizSessionDetailResponse(
id=session.id,
notebook_id=session.notebook_id,
title=session.title,
question_count=session.question_count,
correct_count=session.correct_count,
score=session.score,
difficulty=session.difficulty,
status=session.status,
started_at=session.started_at.isoformat() if session.started_at else None,
completed_at=session.completed_at.isoformat() if session.completed_at else None,
created=session.created.isoformat() if session.created else "",
questions=question_responses
)
except Exception as e:
logger.error(f"Error fetching quiz session: {str(e)}")
raise HTTPException(status_code=404, detail="Quiz session not found")
@router.post("/sessions/{session_id}/answer", response_model=SubmitAnswerResponse)
async def submit_answer(session_id: str, request: SubmitAnswerRequest):
"""Submit an answer for a quiz question"""
try:
session = await QuizSession.get(session_id)
if session.status != "in_progress":
raise HTTPException(status_code=400, detail="Quiz is not in progress")
question = await session.submit_answer(
question_id=request.question_id,
answer=request.answer,
time_spent_seconds=request.time_spent_seconds
)
# Check if all questions answered
questions = await session.get_questions()
answered_count = sum(1 for q in questions if q.user_answer is not None)
return SubmitAnswerResponse(
is_correct=question.is_correct,
correct_index=question.correct_index,
explanation=question.explanation,
session_progress={
"answered": answered_count,
"total": session.question_count,
"correct": session.correct_count
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error submitting answer: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to submit answer")
@router.post("/sessions/{session_id}/complete", response_model=QuizSessionResponse)
async def complete_quiz(session_id: str):
"""Complete a quiz session and calculate final score"""
try:
session = await QuizSession.get(session_id)
session = await session.complete()
# Update user stats (use default user for now)
stats = await UserStudyStats.get_or_create("default_user")
perfect = session.score == 100.0
await stats.record_quiz_completion(session.score, perfect)
return QuizSessionResponse(
id=session.id,
notebook_id=session.notebook_id,
title=session.title,
question_count=session.question_count,
correct_count=session.correct_count,
score=session.score,
difficulty=session.difficulty,
status=session.status,
started_at=session.started_at.isoformat() if session.started_at else None,
completed_at=session.completed_at.isoformat() if session.completed_at else None,
created=session.created.isoformat() if session.created else ""
)
except Exception as e:
logger.error(f"Error completing quiz: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to complete quiz")
# ==================== Flashcard Endpoints ====================
@router.post("/flashcards", response_model=FlashcardResponse)
async def create_flashcard(request: FlashcardCreateRequest):
"""Create a new flashcard"""
try:
flashcard = Flashcard(
notebook_id=request.notebook_id,
source_id=request.source_id,
front=request.front,
back=request.back,
tags=request.tags or []
)
await flashcard.save()
# Award XP for creating flashcard
stats = await UserStudyStats.get_or_create("default_user")
await stats.add_xp(stats.XP_CREATE_FLASHCARD, "Created flashcard")
return FlashcardResponse(
id=flashcard.id,
front=flashcard.front,
back=flashcard.back,
tags=flashcard.tags or [],
difficulty=flashcard.difficulty,
state=flashcard.state,
due=flashcard.due.isoformat() if flashcard.due else None,
reps=flashcard.reps,
created=flashcard.created.isoformat() if flashcard.created else ""
)
except Exception as e:
logger.error(f"Error creating flashcard: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to create flashcard")
@router.post("/flashcards/generate", response_model=List[FlashcardResponse])
async def generate_flashcards(request: FlashcardGenerateRequest):
"""Generate flashcards from notebook content using AI"""
try:
flashcards = await QuizGenerationService.generate_flashcards(
notebook_id=request.notebook_id,
num_cards=request.num_cards,
source_ids=request.source_ids,
model_id=request.model_id
)
return [
FlashcardResponse(
id=f.id,
front=f.front,
back=f.back,
tags=f.tags or [],
difficulty=f.difficulty,
state=f.state,
due=f.due.isoformat() if f.due else None,
reps=f.reps,
created=f.created.isoformat() if f.created else ""
)
for f in flashcards
]
except ValueError as e:
logger.error(f"ValueError generating flashcards: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error generating flashcards: {str(e)}")
logger.exception(e) # Log full traceback
raise HTTPException(status_code=500, detail=f"Failed to generate flashcards: {str(e)}")
@router.get("/flashcards", response_model=List[FlashcardResponse])
async def get_flashcards(
notebook_id: Optional[str] = None,
due_only: bool = False,
limit: int = 100
):
"""Get flashcards, optionally filtered by notebook or due status"""
try:
if due_only:
flashcards = await Flashcard.get_due_cards(notebook_id, limit)
elif notebook_id:
flashcards = await Flashcard.get_by_notebook(notebook_id, limit)
else:
flashcards = await Flashcard.get_all()
return [
FlashcardResponse(
id=f.id,
front=f.front,
back=f.back,
tags=f.tags or [],
difficulty=f.difficulty,
state=f.state,
due=f.due.isoformat() if f.due else None,
reps=f.reps,
created=f.created.isoformat() if f.created else ""
)
for f in flashcards
]
except Exception as e:
logger.error(f"Error fetching flashcards: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to fetch flashcards")
@router.get("/flashcards/stats", response_model=FlashcardStatsResponse)
async def get_flashcard_stats(notebook_id: Optional[str] = None):
"""Get flashcard statistics"""
try:
stats = await Flashcard.get_stats(notebook_id)
return FlashcardStatsResponse(**stats)
except Exception as e:
logger.error(f"Error fetching flashcard stats: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to fetch stats")
@router.post("/flashcards/{flashcard_id}/review", response_model=FlashcardResponse)
async def review_flashcard(flashcard_id: str, request: FlashcardReviewRequest):
"""Review a flashcard and update FSRS scheduling"""
try:
logger.info(f"Reviewing flashcard {flashcard_id} with rating {request.rating}")
flashcard = await Flashcard.get(flashcard_id)
logger.info(f"Found flashcard: {flashcard.front[:50]}")
# Map rating int to FSRS Rating enum
rating_map = {
1: Rating.Again,
2: Rating.Hard,
3: Rating.Good,
4: Rating.Easy
}
rating = rating_map.get(request.rating, Rating.Good)
logger.info(f"Mapped rating to FSRS: {rating}")
flashcard = await flashcard.review(rating)
logger.info(f"Flashcard reviewed successfully, new due: {flashcard.due}")
# Update user stats
stats = await UserStudyStats.get_or_create("default_user")
await stats.record_flashcard_review(correct=request.rating >= 3)
logger.info(f"User stats updated")
return FlashcardResponse(
id=flashcard.id,
front=flashcard.front,
back=flashcard.back,
tags=flashcard.tags or [],
difficulty=flashcard.difficulty if flashcard.difficulty is not None else 0.0,
state=flashcard.state,
due=flashcard.due.isoformat() if flashcard.due else None,
reps=flashcard.reps if hasattr(flashcard, 'reps') and flashcard.reps else 0,
created=flashcard.created.isoformat() if flashcard.created else ""
)
except Exception as e:
logger.error(f"Error reviewing flashcard: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to review flashcard: {str(e)}")
@router.delete("/flashcards/{flashcard_id}")
async def delete_flashcard(flashcard_id: str):
"""Delete a flashcard"""
try:
flashcard = await Flashcard.get(flashcard_id)
await flashcard.delete()
return {"message": "Flashcard deleted"}
except Exception as e:
logger.error(f"Error deleting flashcard: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to delete flashcard")
# ==================== Study Stats Endpoints ====================
@router.get("/stats", response_model=StudyStatsResponse)
async def get_study_stats(user_id: str = "default_user"):
"""Get user study statistics"""
try:
stats = await UserStudyStats.get_or_create(user_id)
xp_to_next = stats.XP_PER_LEVEL - (stats.total_xp % stats.XP_PER_LEVEL)
return StudyStatsResponse(
user_id=stats.user_id,
current_streak=stats.current_streak,
longest_streak=stats.longest_streak,
total_xp=stats.total_xp,
level=stats.level,
badges=stats.badges,
total_quizzes_completed=stats.total_quizzes_completed,
total_flashcards_reviewed=stats.total_flashcards_reviewed,
total_correct_answers=stats.total_correct_answers,
xp_to_next_level=xp_to_next
)
except Exception as e:
logger.error(f"Error fetching study stats: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to fetch study stats")