Initial commit: PDF Quiz Generator app

- FastAPI backend with JWT auth, roles (admin/moderator/user)
- PDF upload (up to 500MB) with streaming, PyMuPDF text extraction
- ChromaDB vectorization per page with metadata
- LiteLLM AI question extraction from PDF (not generation)
- Image extraction from PDF pages, graceful fallback
- Quiz modes: timed (countdown timer) + learning (answers shown inline)
- Page-by-page question navigation with dot navigator
- TTS endpoint using LiteLLM (Google Vertex / OpenAI voices)
- Admin dashboard: AI model management per task, user role management
- Moderator role: upload PDFs, create sections, generate quizzes
- Spaced repetition reminders via SMTP email (SM-2 intervals)
- APScheduler daily reminder jobs
- Celery + Redis for background PDF processing
- React frontend with all pages
- Docker Compose deployment (nginx + backend + celery + redis)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ifedan-ed 2026-03-30 20:04:53 +00:00
commit b876f13fac
72 changed files with 4664 additions and 0 deletions

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
venv/
.env
*.db
*.db-wal
*.db-shm
# Data
uploads/
chroma_data/
# Node
node_modules/
dist/
.npm/
# OS
.DS_Store
*.swp

8
backend/.dockerignore Normal file
View file

@ -0,0 +1,8 @@
venv/
__pycache__/
*.pyc
*.db
uploads/
chroma_data/
.env
alembic/versions/__pycache__/

28
backend/.env.example Normal file
View file

@ -0,0 +1,28 @@
# Database
DATABASE_URL=sqlite:////app/data/quiz.db
SECRET_KEY=change-me-to-a-random-secret-key-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440
# Redis (use service name in Docker)
REDIS_URL=redis://redis:6379/0
# AI - LiteLLM (supports OpenAI, Anthropic, etc.)
LITELLM_MODEL=gpt-4o-mini
LITELLM_API_KEY=your-api-key-here
# Vector store
CHROMA_PERSIST_DIR=/app/chroma_data
# SMTP Email for reminders
MAIL_USERNAME=your-email@example.com
MAIL_PASSWORD=your-app-password
MAIL_FROM=your-email@example.com
MAIL_PORT=587
MAIL_SERVER=smtp.gmail.com
MAIL_STARTTLS=true
MAIL_SSL_TLS=false
# File uploads
UPLOAD_DIR=/app/uploads
MAX_UPLOAD_SIZE=524288000

21
backend/Dockerfile Normal file
View file

@ -0,0 +1,21 @@
FROM python:3.11-slim
WORKDIR /app
# Install system deps
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && \
pip install --no-cache-dir "numpy<2"
COPY . .
# Create dirs
RUN mkdir -p uploads chroma_data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

116
backend/alembic.ini Normal file
View file

@ -0,0 +1,116 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:///./quiz.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View file

@ -0,0 +1 @@
Generic single-database configuration.

77
backend/alembic/env.py Normal file
View file

@ -0,0 +1,77 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
from app.models import User, PDFDocument, Section, Quiz, Question, QuizAttempt, AttemptAnswer, ReminderSchedule # noqa
from app.database import Base
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,142 @@
"""initial tables
Revision ID: 97ef9b957a02
Revises:
Create Date: 2026-03-30 18:20:24.251948
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '97ef9b957a02'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_table('pdf_documents',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('filename', sa.String(), nullable=False),
sa.Column('original_filename', sa.String(), nullable=False),
sa.Column('total_pages', sa.Integer(), nullable=True),
sa.Column('status', sa.String(), nullable=True),
sa.Column('error_message', sa.String(), nullable=True),
sa.Column('uploaded_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_pdf_documents_id'), 'pdf_documents', ['id'], unique=False)
op.create_table('sections',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('document_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('start_page', sa.Integer(), nullable=False),
sa.Column('end_page', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['document_id'], ['pdf_documents.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sections_id'), 'sections', ['id'], unique=False)
op.create_table('quizzes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('section_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('questions_count', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['section_id'], ['sections.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_quizzes_id'), 'quizzes', ['id'], unique=False)
op.create_table('questions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('quiz_id', sa.Integer(), nullable=False),
sa.Column('question_text', sa.Text(), nullable=False),
sa.Column('question_type', sa.String(), nullable=False),
sa.Column('options', sa.JSON(), nullable=True),
sa.Column('correct_answer', sa.String(), nullable=False),
sa.Column('explanation', sa.Text(), nullable=True),
sa.Column('page_reference', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['quiz_id'], ['quizzes.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_questions_id'), 'questions', ['id'], unique=False)
op.create_table('quiz_attempts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('quiz_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('score', sa.Integer(), nullable=True),
sa.Column('total_questions', sa.Integer(), nullable=True),
sa.Column('started_at', sa.DateTime(), nullable=True),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['quiz_id'], ['quizzes.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_quiz_attempts_id'), 'quiz_attempts', ['id'], unique=False)
op.create_table('reminder_schedules',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('quiz_id', sa.Integer(), nullable=False),
sa.Column('next_reminder_at', sa.DateTime(), nullable=False),
sa.Column('interval_days', sa.Integer(), nullable=True),
sa.Column('performance_score', sa.Float(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['quiz_id'], ['quizzes.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_reminder_schedules_id'), 'reminder_schedules', ['id'], unique=False)
op.create_table('attempt_answers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('attempt_id', sa.Integer(), nullable=False),
sa.Column('question_id', sa.Integer(), nullable=False),
sa.Column('user_answer', sa.String(), nullable=False),
sa.Column('is_correct', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['attempt_id'], ['quiz_attempts.id'], ),
sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_attempt_answers_id'), 'attempt_answers', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_attempt_answers_id'), table_name='attempt_answers')
op.drop_table('attempt_answers')
op.drop_index(op.f('ix_reminder_schedules_id'), table_name='reminder_schedules')
op.drop_table('reminder_schedules')
op.drop_index(op.f('ix_quiz_attempts_id'), table_name='quiz_attempts')
op.drop_table('quiz_attempts')
op.drop_index(op.f('ix_questions_id'), table_name='questions')
op.drop_table('questions')
op.drop_index(op.f('ix_quizzes_id'), table_name='quizzes')
op.drop_table('quizzes')
op.drop_index(op.f('ix_sections_id'), table_name='sections')
op.drop_table('sections')
op.drop_index(op.f('ix_pdf_documents_id'), table_name='pdf_documents')
op.drop_table('pdf_documents')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

View file

@ -0,0 +1,51 @@
"""add roles images modes models
Revision ID: c3aafcd58735
Revises: 97ef9b957a02
Create Date: 2026-03-30 19:43:35.616037
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c3aafcd58735'
down_revision: Union[str, None] = '97ef9b957a02'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('ai_model_configs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('model_id', sa.String(), nullable=False),
sa.Column('task', sa.String(), nullable=False),
sa.Column('api_key', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('model_id')
)
op.create_index(op.f('ix_ai_model_configs_id'), 'ai_model_configs', ['id'], unique=False)
op.add_column('questions', sa.Column('image_path', sa.String(), nullable=True))
op.add_column('quizzes', sa.Column('time_limit_minutes', sa.Integer(), nullable=True))
op.add_column('quizzes', sa.Column('mode', sa.String(), nullable=True))
op.add_column('users', sa.Column('role', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'role')
op.drop_column('quizzes', 'mode')
op.drop_column('quizzes', 'time_limit_minutes')
op.drop_column('questions', 'image_path')
op.drop_index(op.f('ix_ai_model_configs_id'), table_name='ai_model_configs')
op.drop_table('ai_model_configs')
# ### end Alembic commands ###

0
backend/app/__init__.py Normal file
View file

31
backend/app/config.py Normal file
View file

@ -0,0 +1,31 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
DATABASE_URL: str = "sqlite:///./quiz.db"
SECRET_KEY: str = "change-me-to-a-random-secret-key-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
REDIS_URL: str = "redis://localhost:6379/0"
LITELLM_MODEL: str = "gpt-4o-mini"
LITELLM_API_KEY: str = ""
CHROMA_PERSIST_DIR: str = "./chroma_data"
MAIL_USERNAME: str = ""
MAIL_PASSWORD: str = ""
MAIL_FROM: str = ""
MAIL_PORT: int = 587
MAIL_SERVER: str = "smtp.gmail.com"
MAIL_STARTTLS: bool = True
MAIL_SSL_TLS: bool = False
UPLOAD_DIR: str = "./uploads"
MAX_UPLOAD_SIZE: int = 524288000 # 500MB
settings = Settings()

28
backend/app/database.py Normal file
View file

@ -0,0 +1,28 @@
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, declarative_base
from app.config import settings
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False},
)
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.close()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

126
backend/app/main.py Normal file
View file

@ -0,0 +1,126 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
def seed_admin():
"""Create default admin user if none exists."""
from app.models.user import User
db = SessionLocal()
try:
admin_exists = db.query(User).filter(User.role == "admin").first()
if not admin_exists:
admin_user = User(
email="admin@quizapp.com",
hashed_password=get_password_hash("admin123"),
name="Admin",
role="admin",
)
db.add(admin_user)
db.commit()
finally:
db.close()
def seed_default_models():
"""Seed default AI model configs if none exist."""
from app.models.ai_model_config import AIModelConfig
db = SessionLocal()
try:
if db.query(AIModelConfig).count() == 0:
defaults = [
AIModelConfig(
name="GPT-4o Mini (Extraction)",
model_id="gpt-4o-mini",
task="extraction",
is_active=True,
is_default=True,
),
AIModelConfig(
name="GPT-4o (Extraction)",
model_id="gpt-4o",
task="extraction",
is_active=True,
is_default=False,
),
AIModelConfig(
name="Claude Sonnet 4.6 (Extraction)",
model_id="anthropic/claude-sonnet-4-6-20250514",
task="extraction",
is_active=True,
is_default=False,
),
AIModelConfig(
name="Google Vertex TTS",
model_id="vertex_ai/google/cloud-tts",
task="tts",
is_active=True,
is_default=True,
),
AIModelConfig(
name="OpenAI TTS",
model_id="openai/tts-1",
task="tts",
is_active=True,
is_default=False,
),
]
db.add_all(defaults)
db.commit()
finally:
db.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
Base.metadata.create_all(bind=engine)
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
os.makedirs(os.path.join(settings.UPLOAD_DIR, "images"), exist_ok=True)
os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
seed_admin()
seed_default_models()
start_scheduler()
yield
# Shutdown
stop_scheduler()
app = FastAPI(
title="PDF Quiz Generator",
description="Convert PDF files into interactive quizzes with AI",
version="2.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:3000", "http://localhost"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Serve uploaded images as static files
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(documents.router, prefix="/api/documents", tags=["documents"])
app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"])
app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"])
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
app.include_router(tts.router, prefix="/api/tts", tags=["tts"])
@app.get("/api/health")
def health_check():
return {"status": "ok"}

View file

@ -0,0 +1,20 @@
from app.models.user import User
from app.models.pdf_document import PDFDocument
from app.models.section import Section
from app.models.quiz import Quiz
from app.models.question import Question
from app.models.attempt import QuizAttempt, AttemptAnswer
from app.models.reminder import ReminderSchedule
from app.models.ai_model_config import AIModelConfig
__all__ = [
"User",
"PDFDocument",
"Section",
"Quiz",
"Question",
"QuizAttempt",
"AttemptAnswer",
"ReminderSchedule",
"AIModelConfig",
]

View file

@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from app.database import Base
class AIModelConfig(Base):
__tablename__ = "ai_model_configs"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False) # display name
model_id = Column(String, nullable=False, unique=True) # litellm model id e.g. gpt-4o-mini
task = Column(String, nullable=False) # extraction, tts, general
api_key = Column(String, nullable=True) # override api key, if null uses default
is_active = Column(Boolean, default=True)
is_default = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)

View file

@ -0,0 +1,35 @@
from datetime import datetime
from sqlalchemy import Column, Integer, Boolean, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class QuizAttempt(Base):
__tablename__ = "quiz_attempts"
id = Column(Integer, primary_key=True, index=True)
quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
score = Column(Integer, default=0)
total_questions = Column(Integer, default=0)
started_at = Column(DateTime, default=datetime.utcnow)
completed_at = Column(DateTime, nullable=True)
quiz = relationship("Quiz", back_populates="attempts")
user = relationship("User", back_populates="attempts")
answers = relationship("AttemptAnswer", back_populates="attempt", cascade="all, delete-orphan")
class AttemptAnswer(Base):
__tablename__ = "attempt_answers"
id = Column(Integer, primary_key=True, index=True)
attempt_id = Column(Integer, ForeignKey("quiz_attempts.id"), nullable=False)
question_id = Column(Integer, ForeignKey("questions.id"), nullable=False)
user_answer = Column(String, nullable=False)
is_correct = Column(Boolean, default=False)
attempt = relationship("QuizAttempt", back_populates="answers")
question = relationship("Question")

View file

@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class PDFDocument(Base):
__tablename__ = "pdf_documents"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
filename = Column(String, nullable=False)
original_filename = Column(String, nullable=False)
total_pages = Column(Integer, nullable=True)
status = Column(String, default="processing") # processing, ready, error
error_message = Column(String, nullable=True)
uploaded_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="documents")
sections = relationship("Section", back_populates="document", cascade="all, delete-orphan")

View file

@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, Text, JSON, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class Question(Base):
__tablename__ = "questions"
id = Column(Integer, primary_key=True, index=True)
quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False)
question_text = Column(Text, nullable=False)
question_type = Column(String, nullable=False) # mcq, true_false, fill_blank
options = Column(JSON, nullable=True) # list of strings for mcq
correct_answer = Column(String, nullable=False)
explanation = Column(Text, nullable=True)
page_reference = Column(Integer, nullable=True)
image_path = Column(String, nullable=True) # path to extracted image, if any
quiz = relationship("Quiz", back_populates="questions")

View file

@ -0,0 +1,24 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class Quiz(Base):
__tablename__ = "quizzes"
id = Column(Integer, primary_key=True, index=True)
section_id = Column(Integer, ForeignKey("sections.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
title = Column(String, nullable=False)
questions_count = Column(Integer, default=0)
time_limit_minutes = Column(Integer, nullable=True) # null = no limit
mode = Column(String, default="timed") # timed, learning
created_at = Column(DateTime, default=datetime.utcnow)
section = relationship("Section", back_populates="quizzes")
user = relationship("User", back_populates="quizzes")
questions = relationship("Question", back_populates="quiz", cascade="all, delete-orphan")
attempts = relationship("QuizAttempt", back_populates="quiz", cascade="all, delete-orphan")

View file

@ -0,0 +1,23 @@
from datetime import datetime
from sqlalchemy import Column, Integer, Float, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class ReminderSchedule(Base):
__tablename__ = "reminder_schedules"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False)
next_reminder_at = Column(DateTime, nullable=False)
interval_days = Column(Integer, default=1)
performance_score = Column(Float, default=0.0)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="reminders")
quiz = relationship("Quiz")

View file

@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class Section(Base):
__tablename__ = "sections"
id = Column(Integer, primary_key=True, index=True)
document_id = Column(Integer, ForeignKey("pdf_documents.id"), nullable=False)
name = Column(String, nullable=False)
start_page = Column(Integer, nullable=False)
end_page = Column(Integer, nullable=False)
document = relationship("PDFDocument", back_populates="sections")
quizzes = relationship("Quiz", back_populates="section", cascade="all, delete-orphan")

View file

@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.orm import relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
name = Column(String, nullable=False)
role = Column(String, default="user") # admin, moderator, user
created_at = Column(DateTime, default=datetime.utcnow)
documents = relationship("PDFDocument", back_populates="user")
quizzes = relationship("Quiz", back_populates="user")
attempts = relationship("QuizAttempt", back_populates="user")
reminders = relationship("ReminderSchedule", back_populates="user")
@property
def is_admin(self):
return self.role == "admin"
@property
def is_moderator(self):
return self.role in ("admin", "moderator")

View file

View file

@ -0,0 +1,119 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.models.ai_model_config import AIModelConfig
from app.schemas.auth import UserResponse, UserUpdateRole
from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate
from app.utils.auth import require_admin
router = APIRouter()
# --- User Management ---
@router.get("/users", response_model=list[UserResponse])
def list_users(
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
return db.query(User).order_by(User.created_at.desc()).all()
@router.put("/users/{user_id}/role", response_model=UserResponse)
def update_user_role(
user_id: int,
role_data: UserUpdateRole,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
if role_data.role not in ("admin", "moderator", "user"):
raise HTTPException(status_code=400, detail="Role must be admin, moderator, or user")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot change your own role")
user.role = role_data.role
db.commit()
db.refresh(user)
return user
# --- AI Model Configuration ---
@router.get("/models", response_model=list[AIModelConfigResponse])
def list_models(
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
return db.query(AIModelConfig).order_by(AIModelConfig.task, AIModelConfig.name).all()
@router.post("/models", response_model=AIModelConfigResponse)
def create_model(
data: AIModelConfigCreate,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
if data.task not in ("extraction", "tts", "general"):
raise HTTPException(status_code=400, detail="Task must be extraction, tts, or general")
# If setting as default, unset other defaults for same task
if data.is_default:
db.query(AIModelConfig).filter(
AIModelConfig.task == data.task,
AIModelConfig.is_default == True,
).update({"is_default": False})
model = AIModelConfig(**data.model_dump())
db.add(model)
db.commit()
db.refresh(model)
return model
@router.put("/models/{model_id}", response_model=AIModelConfigResponse)
def update_model(
model_id: int,
data: AIModelConfigUpdate,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
if not model:
raise HTTPException(status_code=404, detail="Model config not found")
update_data = data.model_dump(exclude_unset=True)
# If setting as default, unset other defaults for same task
task = update_data.get("task", model.task)
if update_data.get("is_default"):
db.query(AIModelConfig).filter(
AIModelConfig.task == task,
AIModelConfig.is_default == True,
AIModelConfig.id != model_id,
).update({"is_default": False})
for key, value in update_data.items():
setattr(model, key, value)
db.commit()
db.refresh(model)
return model
@router.delete("/models/{model_id}", status_code=204)
def delete_model(
model_id: int,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
if not model:
raise HTTPException(status_code=404, detail="Model config not found")
db.delete(model)
db.commit()

View file

@ -0,0 +1,236 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.models.quiz import Quiz
from app.models.question import Question
from app.models.attempt import QuizAttempt, AttemptAnswer
from app.models.pdf_document import PDFDocument
from app.models.user import User
from app.schemas.attempt import (
AttemptSubmit,
AttemptResponse,
AttemptDetail,
AnswerDetail,
DashboardStats,
QuizStats,
)
from app.utils.auth import get_current_user
router = APIRouter()
@router.post("/start", response_model=AttemptResponse)
def start_attempt(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.user_id == current_user.id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
attempt = QuizAttempt(
quiz_id=quiz_id,
user_id=current_user.id,
total_questions=quiz.questions_count,
)
db.add(attempt)
db.commit()
db.refresh(attempt)
return AttemptResponse(
id=attempt.id,
quiz_id=attempt.quiz_id,
score=0,
total_questions=attempt.total_questions,
percentage=0.0,
started_at=attempt.started_at,
completed_at=None,
)
@router.post("/{attempt_id}/submit", response_model=AttemptDetail)
def submit_attempt(
attempt_id: int,
submission: AttemptSubmit,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
attempt = db.query(QuizAttempt).filter(
QuizAttempt.id == attempt_id,
QuizAttempt.user_id == current_user.id,
).first()
if not attempt:
raise HTTPException(status_code=404, detail="Attempt not found")
if attempt.completed_at:
raise HTTPException(status_code=400, detail="Attempt already submitted")
# Get all questions for this quiz
questions = {
q.id: q for q in db.query(Question).filter(Question.quiz_id == attempt.quiz_id).all()
}
score = 0
answer_details = []
for ans in submission.answers:
question = questions.get(ans.question_id)
if not question:
continue
is_correct = ans.user_answer.strip().lower() == question.correct_answer.strip().lower()
if is_correct:
score += 1
attempt_answer = AttemptAnswer(
attempt_id=attempt_id,
question_id=ans.question_id,
user_answer=ans.user_answer,
is_correct=is_correct,
)
db.add(attempt_answer)
answer_details.append(AnswerDetail(
question_id=question.id,
question_text=question.question_text,
question_type=question.question_type,
user_answer=ans.user_answer,
correct_answer=question.correct_answer,
is_correct=is_correct,
explanation=question.explanation,
))
attempt.score = score
attempt.completed_at = datetime.utcnow()
db.commit()
percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
# Update reminder schedule
try:
from app.services.reminder_service import update_reminder_schedule
update_reminder_schedule(db, current_user.id, attempt.quiz_id, percentage)
except Exception:
pass # Don't fail submission if reminder update fails
return AttemptDetail(
id=attempt.id,
quiz_id=attempt.quiz_id,
score=score,
total_questions=attempt.total_questions,
percentage=round(percentage, 1),
started_at=attempt.started_at,
completed_at=attempt.completed_at,
answers=answer_details,
)
@router.get("/", response_model=list[AttemptResponse])
def list_attempts(
quiz_id: int | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = db.query(QuizAttempt).filter(QuizAttempt.user_id == current_user.id)
if quiz_id:
query = query.filter(QuizAttempt.quiz_id == quiz_id)
attempts = query.order_by(QuizAttempt.started_at.desc()).all()
return [
AttemptResponse(
id=a.id,
quiz_id=a.quiz_id,
score=a.score,
total_questions=a.total_questions,
percentage=round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1),
started_at=a.started_at,
completed_at=a.completed_at,
)
for a in attempts
]
@router.get("/stats/dashboard", response_model=DashboardStats)
def get_dashboard_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
total_docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).count()
total_quizzes = db.query(Quiz).filter(Quiz.user_id == current_user.id).count()
completed_attempts = db.query(QuizAttempt).filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
).all()
total_attempts = len(completed_attempts)
avg_score = 0.0
if completed_attempts:
scores = [(a.score / a.total_questions * 100) if a.total_questions > 0 else 0 for a in completed_attempts]
avg_score = round(sum(scores) / len(scores), 1)
# Per-quiz stats
quiz_stats = []
quizzes = db.query(Quiz).filter(Quiz.user_id == current_user.id).all()
for quiz in quizzes:
quiz_attempts = [a for a in completed_attempts if a.quiz_id == quiz.id]
if quiz_attempts:
pcts = [(a.score / a.total_questions * 100) if a.total_questions > 0 else 0 for a in quiz_attempts]
quiz_stats.append(QuizStats(
quiz_id=quiz.id,
quiz_title=quiz.title,
attempts_count=len(quiz_attempts),
best_score=round(max(pcts), 1),
latest_score=round(pcts[-1], 1),
average_score=round(sum(pcts) / len(pcts), 1),
))
return DashboardStats(
total_documents=total_docs,
total_quizzes=total_quizzes,
total_attempts=total_attempts,
average_score=avg_score,
quiz_stats=quiz_stats,
)
@router.get("/{attempt_id}", response_model=AttemptDetail)
def get_attempt(
attempt_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
attempt = db.query(QuizAttempt).filter(
QuizAttempt.id == attempt_id,
QuizAttempt.user_id == current_user.id,
).first()
if not attempt:
raise HTTPException(status_code=404, detail="Attempt not found")
answer_details = []
for ans in attempt.answers:
question = ans.question
answer_details.append(AnswerDetail(
question_id=question.id,
question_text=question.question_text,
question_type=question.question_type,
user_answer=ans.user_answer,
correct_answer=question.correct_answer,
is_correct=ans.is_correct,
explanation=question.explanation,
))
percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
return AttemptDetail(
id=attempt.id,
quiz_id=attempt.quiz_id,
score=attempt.score,
total_questions=attempt.total_questions,
percentage=round(percentage, 1),
started_at=attempt.started_at,
completed_at=attempt.completed_at,
answers=answer_details,
)

View file

@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.schemas.auth import UserCreate, UserResponse, Token, LoginRequest
from app.utils.auth import get_password_hash, verify_password, create_access_token, get_current_user
router = APIRouter()
@router.post("/register", response_model=Token)
def register(user_data: UserCreate, db: Session = Depends(get_db)):
existing = db.query(User).filter(User.email == user_data.email).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
user = User(
email=user_data.email,
hashed_password=get_password_hash(user_data.password),
name=user_data.name,
)
db.add(user)
db.commit()
db.refresh(user)
access_token = create_access_token(data={"sub": user.email})
return Token(access_token=access_token)
@router.post("/login", response_model=Token)
def login(login_data: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == login_data.email).first()
if not user or not verify_password(login_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
access_token = create_access_token(data={"sub": user.email})
return Token(access_token=access_token)
@router.get("/me", response_model=UserResponse)
def get_me(current_user: User = Depends(get_current_user)):
return current_user

View file

@ -0,0 +1,180 @@
import os
import uuid
import shutil
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.pdf_document import PDFDocument
from app.models.section import Section
from app.models.user import User
from app.schemas.document import DocumentResponse, DocumentStatusResponse, SectionCreate, SectionResponse
from app.utils.auth import get_current_user, require_moderator
from app.services import vector_service
router = APIRouter()
@router.post("/upload", response_model=DocumentResponse)
def upload_document(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
if not file.filename or not file.filename.lower().endswith(".pdf"):
raise HTTPException(status_code=400, detail="Only PDF files are accepted")
# Save file to disk streaming (handles large files)
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
safe_name = f"{uuid.uuid4()}_{file.filename}"
file_path = os.path.join(settings.UPLOAD_DIR, safe_name)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer, length=1024 * 1024)
# Check file size
file_size = os.path.getsize(file_path)
if file_size > settings.MAX_UPLOAD_SIZE:
os.remove(file_path)
raise HTTPException(status_code=400, detail=f"File too large. Max size: {settings.MAX_UPLOAD_SIZE} bytes")
# Create DB record
doc = PDFDocument(
user_id=current_user.id,
filename=safe_name,
original_filename=file.filename,
status="processing",
)
db.add(doc)
db.commit()
db.refresh(doc)
# Dispatch background processing
try:
from app.tasks.pdf_tasks import process_pdf
process_pdf.delay(doc.id, file_path)
except Exception:
# If Celery/Redis not available, process synchronously
from app.services import pdf_service
try:
total_pages = pdf_service.get_page_count(file_path)
doc.total_pages = total_pages
pages = pdf_service.extract_text_by_page(file_path)
if pages:
vector_service.store_pages(doc.id, pages)
doc.status = "ready"
else:
doc.status = "error"
doc.error_message = "No text could be extracted"
db.commit()
except Exception as e:
doc.status = "error"
doc.error_message = str(e)[:500]
db.commit()
db.refresh(doc)
return doc
@router.get("/", response_model=list[DocumentResponse])
def list_documents(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.is_moderator:
docs = db.query(PDFDocument).order_by(PDFDocument.uploaded_at.desc()).all()
else:
docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).order_by(PDFDocument.uploaded_at.desc()).all()
return docs
@router.get("/{document_id}", response_model=DocumentResponse)
def get_document(
document_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
doc = db.query(PDFDocument).filter(
PDFDocument.id == document_id,
PDFDocument.user_id == current_user.id,
).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
return doc
@router.get("/{document_id}/status", response_model=DocumentStatusResponse)
def get_document_status(
document_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
doc = db.query(PDFDocument).filter(
PDFDocument.id == document_id,
PDFDocument.user_id == current_user.id,
).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
return DocumentStatusResponse(
id=doc.id,
status=doc.status,
total_pages=doc.total_pages,
error_message=doc.error_message,
)
@router.post("/{document_id}/sections", response_model=SectionResponse)
def create_section(
document_id: int,
section_data: SectionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
doc = db.query(PDFDocument).filter(
PDFDocument.id == document_id,
PDFDocument.user_id == current_user.id,
).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
if doc.status != "ready":
raise HTTPException(status_code=400, detail="Document is not ready yet")
if doc.total_pages and section_data.end_page > doc.total_pages:
raise HTTPException(status_code=400, detail=f"end_page exceeds document length ({doc.total_pages} pages)")
section = Section(
document_id=document_id,
name=section_data.name,
start_page=section_data.start_page,
end_page=section_data.end_page,
)
db.add(section)
db.commit()
db.refresh(section)
return section
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_document(
document_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
doc = db.query(PDFDocument).filter(
PDFDocument.id == document_id,
PDFDocument.user_id == current_user.id,
).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
# Delete file
file_path = os.path.join(settings.UPLOAD_DIR, doc.filename)
if os.path.exists(file_path):
os.remove(file_path)
# Delete ChromaDB collection
vector_service.delete_collection(document_id)
db.delete(doc)
db.commit()

View file

@ -0,0 +1,109 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.quiz import Quiz
from app.models.section import Section
from app.models.attempt import QuizAttempt
from app.models.user import User
from app.schemas.quiz import QuizCreate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview
from app.services import quiz_service
from app.utils.auth import get_current_user, require_moderator
router = APIRouter()
@router.post("/", response_model=QuizResponse)
def create_quiz(
quiz_data: QuizCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Create quiz by extracting questions from PDF section. Moderator/Admin only."""
section = db.query(Section).filter(Section.id == quiz_data.section_id).first()
if not section:
raise HTTPException(status_code=404, detail="Section not found")
if not current_user.is_admin and section.document.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not your document")
if quiz_data.mode not in ("timed", "learning"):
raise HTTPException(status_code=400, detail="Mode must be 'timed' or 'learning'")
try:
quiz = quiz_service.create_quiz_from_section(
db=db,
user_id=current_user.id,
section_id=quiz_data.section_id,
title=quiz_data.title,
mode=quiz_data.mode,
time_limit_minutes=quiz_data.time_limit_minutes,
)
return quiz
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/", response_model=list[QuizResponse])
def list_quizzes(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all available quizzes. Admins/mods see all, users see all published."""
if current_user.is_moderator:
quizzes = db.query(Quiz).order_by(Quiz.created_at.desc()).all()
else:
quizzes = db.query(Quiz).order_by(Quiz.created_at.desc()).all()
return quizzes
@router.get("/{quiz_id}")
def get_quiz(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get quiz for taking. Learning mode includes answers."""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
if quiz.mode == "learning":
return QuizLearningDetail.model_validate(quiz)
return QuizDetail.model_validate(quiz)
@router.get("/{quiz_id}/review", response_model=QuizReview)
def review_quiz(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get quiz with answers — only if user has completed an attempt."""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
has_attempt = db.query(QuizAttempt).filter(
QuizAttempt.quiz_id == quiz_id,
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
).first()
if not has_attempt and not current_user.is_moderator:
raise HTTPException(status_code=403, detail="Complete an attempt first to review answers")
return quiz
@router.delete("/{quiz_id}", status_code=204)
def delete_quiz(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
db.delete(quiz)
db.commit()

View file

@ -0,0 +1,38 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.services import ai_service
from app.utils.auth import get_current_user
router = APIRouter()
class TTSRequest(BaseModel):
text: str
@router.post("/speak")
def text_to_speech(
request: TTSRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Convert text to speech using configured TTS model."""
if not request.text.strip():
raise HTTPException(status_code=400, detail="Text cannot be empty")
# Limit text length
text = request.text[:2000]
# Get TTS model config
model_id, api_key = ai_service.get_model_for_task(db, "tts")
audio = ai_service.generate_tts_audio(text, model_id=model_id, api_key=api_key)
if audio is None:
raise HTTPException(status_code=500, detail="TTS generation failed. Check model configuration.")
return Response(content=audio, media_type="audio/mpeg")

View file

View file

@ -0,0 +1,37 @@
from datetime import datetime
from pydantic import BaseModel
class AIModelConfigCreate(BaseModel):
model_config = {"protected_namespaces": ()}
name: str
model_id: str
task: str # extraction, tts, general
api_key: str | None = None
is_active: bool = True
is_default: bool = False
class AIModelConfigResponse(BaseModel):
model_config = {"protected_namespaces": ()}
id: int
name: str
model_id: str
task: str
is_active: bool
is_default: bool
created_at: datetime
class Config:
from_attributes = True
class AIModelConfigUpdate(BaseModel):
model_config = {"protected_namespaces": ()}
name: str | None = None
model_id: str | None = None
task: str | None = None
api_key: str | None = None
is_active: bool | None = None
is_default: bool | None = None

View file

@ -0,0 +1,60 @@
from datetime import datetime
from pydantic import BaseModel
class AnswerSubmission(BaseModel):
question_id: int
user_answer: str
class AttemptSubmit(BaseModel):
answers: list[AnswerSubmission]
class AnswerDetail(BaseModel):
question_id: int
question_text: str
question_type: str
user_answer: str
correct_answer: str
is_correct: bool
explanation: str | None
class Config:
from_attributes = True
class AttemptResponse(BaseModel):
id: int
quiz_id: int
score: int
total_questions: int
percentage: float
started_at: datetime
completed_at: datetime | None
class Config:
from_attributes = True
class AttemptDetail(AttemptResponse):
answers: list[AnswerDetail] = []
# Stats schemas
class QuizStats(BaseModel):
quiz_id: int
quiz_title: str
attempts_count: int
best_score: float
latest_score: float
average_score: float
class DashboardStats(BaseModel):
total_documents: int
total_quizzes: int
total_attempts: int
average_score: float
quiz_stats: list[QuizStats] = []

View file

@ -0,0 +1,34 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
email: EmailStr
password: str
name: str
class UserResponse(BaseModel):
id: int
email: str
name: str
role: str
created_at: datetime
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class LoginRequest(BaseModel):
email: EmailStr
password: str
class UserUpdateRole(BaseModel):
role: str # admin, moderator, user

View file

@ -0,0 +1,55 @@
from datetime import datetime
from pydantic import BaseModel, field_validator
class SectionCreate(BaseModel):
name: str
start_page: int
end_page: int
@field_validator("end_page")
@classmethod
def end_after_start(cls, v, info):
if "start_page" in info.data and v <= info.data["start_page"]:
raise ValueError("end_page must be greater than start_page")
return v
@field_validator("start_page")
@classmethod
def start_positive(cls, v):
if v < 1:
raise ValueError("start_page must be at least 1")
return v
class SectionResponse(BaseModel):
id: int
document_id: int
name: str
start_page: int
end_page: int
class Config:
from_attributes = True
class DocumentResponse(BaseModel):
id: int
user_id: int
original_filename: str
total_pages: int | None
status: str
error_message: str | None
uploaded_at: datetime
sections: list[SectionResponse] = []
class Config:
from_attributes = True
class DocumentStatusResponse(BaseModel):
id: int
status: str
total_pages: int | None
error_message: str | None

View file

@ -0,0 +1,54 @@
from datetime import datetime
from pydantic import BaseModel
class QuizCreate(BaseModel):
section_id: int
title: str
mode: str = "timed" # timed, learning
time_limit_minutes: int | None = None
class QuestionResponse(BaseModel):
id: int
question_text: str
question_type: str
options: list[str] | None
image_path: str | None = None
class Config:
from_attributes = True
class QuestionWithAnswer(QuestionResponse):
correct_answer: str
explanation: str | None
page_reference: int | None
class QuizResponse(BaseModel):
id: int
section_id: int
user_id: int
title: str
questions_count: int
mode: str
time_limit_minutes: int | None
created_at: datetime
class Config:
from_attributes = True
class QuizDetail(QuizResponse):
questions: list[QuestionResponse] = []
class QuizLearningDetail(QuizResponse):
"""Learning mode — includes answers and explanations."""
questions: list[QuestionWithAnswer] = []
class QuizReview(QuizResponse):
questions: list[QuestionWithAnswer] = []

View file

View file

@ -0,0 +1,162 @@
import json
import logging
import litellm
from app.config import settings
logger = logging.getLogger(__name__)
EXTRACTION_PROMPT = """You are a quiz question extractor. The following content is from a PDF that contains quiz/exam questions with answers and explanations.
Your job is to EXTRACT (not generate) all the questions, their options, correct answers, and explanations exactly as they appear in the content.
Return a JSON object with a "questions" key containing an array where each object has:
- "question_text": the full question text as it appears
- "question_type": "mcq" if it has multiple choice options, "true_false" if True/False, "fill_blank" if fill-in-the-blank
- "options": array of option strings exactly as written (for mcq), ["True", "False"] (for true_false), or null (for fill_blank)
- "correct_answer": the correct answer exactly as marked in the source
- "explanation": the explanation text if provided, or "" if none
- "page_reference": {page_ref}
Important:
- Extract ALL questions found in the content do not skip any
- Preserve the original wording exactly
- If a question has an image reference, include "[IMAGE]" in the question_text where the image would be
- If no clear correct answer is marked, use the best answer based on the explanation
Content from page(s) {page_info}:
{content}
Return ONLY valid JSON. No markdown formatting."""
def get_model_for_task(db, task: str = "extraction") -> tuple[str, str | None]:
"""Get the configured model for a specific task from DB, or fall back to settings."""
try:
from app.models.ai_model_config import AIModelConfig
config = db.query(AIModelConfig).filter(
AIModelConfig.task == task,
AIModelConfig.is_active == True,
AIModelConfig.is_default == True,
).first()
if config:
return config.model_id, config.api_key
except Exception:
pass
return settings.LITELLM_MODEL, settings.LITELLM_API_KEY or None
def _truncate_content(content: str, max_chars: int = 100000) -> str:
if len(content) <= max_chars:
return content
half = max_chars // 2
return content[:half] + "\n\n... [content truncated] ...\n\n" + content[-half:]
def extract_questions(
content: str,
page_info: str = "unknown",
page_ref: int | None = None,
model_id: str | None = None,
api_key: str | None = None,
) -> list[dict]:
"""Extract quiz questions from PDF content using LiteLLM."""
content = _truncate_content(content)
prompt = EXTRACTION_PROMPT.format(
content=content,
page_info=page_info,
page_ref=page_ref if page_ref else "null",
)
use_model = model_id or settings.LITELLM_MODEL
use_key = api_key or settings.LITELLM_API_KEY
last_error = None
for attempt in range(3):
try:
kwargs = {
"model": use_model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.1, # low temp for faithful extraction
}
if use_key:
kwargs["api_key"] = use_key
# Try JSON mode if supported
try:
kwargs["response_format"] = {"type": "json_object"}
response = litellm.completion(**kwargs)
except Exception:
kwargs.pop("response_format", None)
response = litellm.completion(**kwargs)
response_text = response.choices[0].message.content
# Try to parse JSON, handle markdown code blocks
text = response_text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
data = json.loads(text)
questions = data.get("questions", data) if isinstance(data, dict) else data
if not isinstance(questions, list):
raise ValueError("Response is not a list of questions")
validated = []
for q in questions:
if not all(k in q for k in ("question_text", "correct_answer")):
continue
qtype = q.get("question_type", "mcq")
if qtype not in ("mcq", "true_false", "fill_blank"):
qtype = "mcq"
validated.append({
"question_text": q["question_text"],
"question_type": qtype,
"options": q.get("options"),
"correct_answer": q["correct_answer"],
"explanation": q.get("explanation", ""),
"page_reference": q.get("page_reference"),
})
if validated:
return validated
raise ValueError("No valid questions extracted from content")
except Exception as e:
last_error = e
logger.warning(f"Extraction attempt {attempt + 1} failed: {e}")
raise RuntimeError(f"Failed to extract questions after 3 attempts: {last_error}")
def generate_tts_audio(
text: str,
model_id: str | None = None,
api_key: str | None = None,
) -> bytes | None:
"""Generate TTS audio using LiteLLM (supports Google Vertex, OpenAI, etc.)."""
use_model = model_id or "vertex_ai/google/cloud-tts"
use_key = api_key or settings.LITELLM_API_KEY
try:
# LiteLLM speech endpoint
response = litellm.speech(
model=use_model,
input=text,
api_key=use_key if use_key else None,
)
# Response is audio bytes
if hasattr(response, "content"):
return response.content
if hasattr(response, "read"):
return response.read()
return bytes(response)
except Exception as e:
logger.error(f"TTS generation failed: {e}")
return None

View file

@ -0,0 +1,64 @@
import logging
from fastapi_mail import FastMail, MessageSchema, MessageType, ConnectionConfig
from app.config import settings
logger = logging.getLogger(__name__)
def get_mail_config() -> ConnectionConfig:
return ConnectionConfig(
MAIL_USERNAME=settings.MAIL_USERNAME,
MAIL_PASSWORD=settings.MAIL_PASSWORD,
MAIL_FROM=settings.MAIL_FROM,
MAIL_PORT=settings.MAIL_PORT,
MAIL_SERVER=settings.MAIL_SERVER,
MAIL_STARTTLS=settings.MAIL_STARTTLS,
MAIL_SSL_TLS=settings.MAIL_SSL_TLS,
USE_CREDENTIALS=bool(settings.MAIL_USERNAME and settings.MAIL_PASSWORD),
)
async def send_reminder_email(
email: str,
user_name: str,
quiz_title: str,
score: float,
next_date: str,
):
"""Send a spaced-repetition reminder email."""
html = f"""
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #2563eb;">Quiz Reminder</h2>
<p>Hi {user_name},</p>
<p>It's time to review <strong>{quiz_title}</strong>!</p>
<p>Your last score was <strong>{score:.0f}%</strong>.
{'Great progress! Keep it up.' if score >= 70 else 'Practice makes perfect — give it another try!'}</p>
<div style="background: #f3f4f6; padding: 16px; border-radius: 8px; margin: 16px 0;">
<p style="margin: 0;"><strong>Tip:</strong> Spaced repetition helps you remember more over time.
Each review strengthens your memory!</p>
</div>
<p>Log in to take the quiz again.</p>
<p style="color: #6b7280; font-size: 12px;">
You're receiving this because you have active quiz reminders.
</p>
</body>
</html>
"""
message = MessageSchema(
subject=f"Quiz Reminder: {quiz_title}",
recipients=[email],
body=html,
subtype=MessageType.html,
)
try:
conf = get_mail_config()
fm = FastMail(conf)
await fm.send_message(message)
logger.info(f"Reminder sent to {email} for quiz '{quiz_title}'")
except Exception as e:
logger.error(f"Failed to send reminder to {email}: {e}")

View file

@ -0,0 +1,103 @@
import os
import logging
import fitz # PyMuPDF
from app.config import settings
logger = logging.getLogger(__name__)
def get_page_count(file_path: str) -> int:
doc = fitz.open(file_path)
count = len(doc)
doc.close()
return count
def extract_text_by_page(file_path: str) -> dict[int, str]:
"""Extract text from every page. Returns {page_num: text} (1-indexed)."""
doc = fitz.open(file_path)
pages = {}
for i in range(len(doc)):
text = doc[i].get_text()
if text.strip():
pages[i + 1] = text
doc.close()
return pages
def extract_text_for_range(file_path: str, start: int, end: int) -> str:
"""Extract text for a page range (1-indexed, inclusive)."""
doc = fitz.open(file_path)
texts = []
for i in range(start - 1, min(end, len(doc))):
text = doc[i].get_text()
if text.strip():
texts.append(f"--- Page {i + 1} ---\n{text}")
doc.close()
return "\n\n".join(texts)
def extract_images_from_page(file_path: str, page_num: int, document_id: int) -> list[str]:
"""Extract images from a specific page. Returns list of saved image paths."""
image_dir = os.path.join(settings.UPLOAD_DIR, "images", f"doc_{document_id}")
os.makedirs(image_dir, exist_ok=True)
saved_paths = []
try:
doc = fitz.open(file_path)
if page_num < 1 or page_num > len(doc):
doc.close()
return []
page = doc[page_num - 1]
images = page.get_images(full=True)
for img_idx, img_info in enumerate(images):
xref = img_info[0]
try:
base_image = doc.extract_image(xref)
if not base_image:
continue
image_ext = base_image.get("ext", "png")
image_bytes = base_image["image"]
# Skip tiny images (likely icons/bullets)
if len(image_bytes) < 1000:
continue
filename = f"page_{page_num}_img_{img_idx}.{image_ext}"
filepath = os.path.join(image_dir, filename)
with open(filepath, "wb") as f:
f.write(image_bytes)
# Return relative path for serving
rel_path = f"images/doc_{document_id}/{filename}"
saved_paths.append(rel_path)
except Exception as e:
logger.warning(f"Failed to extract image {img_idx} from page {page_num}: {e}")
continue
doc.close()
except Exception as e:
logger.warning(f"Image extraction failed for page {page_num}: {e}")
return saved_paths
def extract_all_images(file_path: str, document_id: int, start_page: int = 1, end_page: int | None = None) -> dict[int, list[str]]:
"""Extract images from a page range. Returns {page_num: [image_paths]}."""
doc = fitz.open(file_path)
if end_page is None:
end_page = len(doc)
doc.close()
result = {}
for page_num in range(start_page, end_page + 1):
images = extract_images_from_page(file_path, page_num, document_id)
if images:
result[page_num] = images
return result

View file

@ -0,0 +1,104 @@
import os
import logging
from sqlalchemy.orm import Session
from app.config import settings
from app.models.section import Section
from app.models.pdf_document import PDFDocument
from app.models.quiz import Quiz
from app.models.question import Question
from app.services import ai_service, vector_service, pdf_service
logger = logging.getLogger(__name__)
def create_quiz_from_section(
db: Session,
user_id: int,
section_id: int,
title: str,
mode: str = "timed",
time_limit_minutes: int | None = None,
) -> Quiz:
"""Extract questions from a section's page range using AI."""
section = db.query(Section).filter(Section.id == section_id).first()
if not section:
raise ValueError("Section not found")
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
if not document:
raise ValueError("Document not found")
# Get text from vector store for this page range
content = vector_service.get_pages_text(
document_id=section.document_id,
start_page=section.start_page,
end_page=section.end_page,
)
if not content:
raise ValueError("No content found for this section's page range")
# Get configured model
model_id, api_key = ai_service.get_model_for_task(db, "extraction")
# Extract questions via AI (not generate — questions already exist in PDF)
page_info = f"{section.start_page}-{section.end_page}"
question_data = ai_service.extract_questions(
content,
page_info=page_info,
model_id=model_id,
api_key=api_key,
)
# Extract images for the page range
file_path = os.path.join(settings.UPLOAD_DIR, document.filename)
page_images = {}
if os.path.exists(file_path):
try:
page_images = pdf_service.extract_all_images(
file_path, document.id, section.start_page, section.end_page
)
except Exception as e:
logger.warning(f"Image extraction failed: {e}")
# Create quiz
quiz = Quiz(
section_id=section_id,
user_id=user_id,
title=title,
questions_count=len(question_data),
mode=mode,
time_limit_minutes=time_limit_minutes,
)
db.add(quiz)
db.flush()
# Create question records, associating images where possible
for q in question_data:
page_ref = q.get("page_reference")
image_path = None
# Try to associate an image with this question
if page_ref and page_ref in page_images and page_images[page_ref]:
# Take the first unassigned image from this page
image_path = page_images[page_ref].pop(0)
if not page_images[page_ref]:
del page_images[page_ref]
question = Question(
quiz_id=quiz.id,
question_text=q["question_text"],
question_type=q["question_type"],
options=q.get("options"),
correct_answer=q["correct_answer"],
explanation=q.get("explanation", ""),
page_reference=page_ref,
image_path=image_path,
)
db.add(question)
db.commit()
db.refresh(quiz)
return quiz

View file

@ -0,0 +1,61 @@
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from app.models.reminder import ReminderSchedule
# SM-2 simplified intervals in days
INTERVALS = [1, 3, 7, 14, 30]
def update_reminder_schedule(
db: Session,
user_id: int,
quiz_id: int,
score_percentage: float,
):
"""Update or create a reminder schedule based on quiz performance."""
reminder = db.query(ReminderSchedule).filter(
ReminderSchedule.user_id == user_id,
ReminderSchedule.quiz_id == quiz_id,
).first()
if not reminder:
reminder = ReminderSchedule(
user_id=user_id,
quiz_id=quiz_id,
performance_score=score_percentage,
interval_days=INTERVALS[0],
next_reminder_at=datetime.utcnow() + timedelta(days=INTERVALS[0]),
is_active=True,
)
db.add(reminder)
else:
reminder.performance_score = score_percentage
reminder.is_active = True
# Find current interval index
current_idx = 0
for i, interval in enumerate(INTERVALS):
if reminder.interval_days <= interval:
current_idx = i
break
if score_percentage < 70:
# Poor performance: reset to shortest interval
new_idx = 0
elif score_percentage < 90:
# Decent performance: advance one step
new_idx = min(current_idx + 1, len(INTERVALS) - 1)
else:
# Excellent performance: advance or deactivate
if current_idx >= len(INTERVALS) - 1:
reminder.is_active = False
db.commit()
return
new_idx = min(current_idx + 2, len(INTERVALS) - 1)
reminder.interval_days = INTERVALS[new_idx]
reminder.next_reminder_at = datetime.utcnow() + timedelta(days=INTERVALS[new_idx])
db.commit()

View file

@ -0,0 +1,145 @@
import chromadb
from app.config import settings
_client = None
def get_client() -> chromadb.PersistentClient:
global _client
if _client is None:
_client = chromadb.PersistentClient(path=settings.CHROMA_PERSIST_DIR)
return _client
def get_or_create_collection(document_id: int):
client = get_client()
return client.get_or_create_collection(name=f"doc_{document_id}")
def delete_collection(document_id: int):
client = get_client()
try:
client.delete_collection(name=f"doc_{document_id}")
except Exception:
pass
def chunk_text(text: str, chunk_size: int = 1000, overlap: int = 200) -> list[str]:
"""Split text into overlapping chunks."""
if len(text) <= chunk_size:
return [text]
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
start = end - overlap
return chunks
def store_pages(document_id: int, pages: dict[int, str]):
"""Store page text as chunked embeddings in ChromaDB."""
collection = get_or_create_collection(document_id)
all_ids = []
all_docs = []
all_metadatas = []
for page_num, text in pages.items():
chunks = chunk_text(text)
for i, chunk in enumerate(chunks):
doc_id = f"doc_{document_id}_page_{page_num}_chunk_{i}"
all_ids.append(doc_id)
all_docs.append(chunk)
all_metadatas.append({"page_num": page_num, "document_id": document_id})
# ChromaDB has a batch limit; add in batches of 500
batch_size = 500
for i in range(0, len(all_ids), batch_size):
collection.add(
ids=all_ids[i:i + batch_size],
documents=all_docs[i:i + batch_size],
metadatas=all_metadatas[i:i + batch_size],
)
def query_pages(
document_id: int,
query: str,
start_page: int | None = None,
end_page: int | None = None,
n_results: int = 20,
) -> list[dict]:
"""Query vectorized content with optional page range filter."""
collection = get_or_create_collection(document_id)
where_filter = None
if start_page is not None and end_page is not None:
where_filter = {
"$and": [
{"page_num": {"$gte": start_page}},
{"page_num": {"$lte": end_page}},
]
}
results = collection.query(
query_texts=[query],
n_results=n_results,
where=where_filter,
)
docs = []
if results and results["documents"]:
for i, doc in enumerate(results["documents"][0]):
meta = results["metadatas"][0][i] if results["metadatas"] else {}
docs.append({"text": doc, "page_num": meta.get("page_num")})
return docs
def get_pages_text(document_id: int, start_page: int, end_page: int) -> str:
"""Retrieve all stored text for a page range, ordered by page number."""
collection = get_or_create_collection(document_id)
where_filter = {
"$and": [
{"page_num": {"$gte": start_page}},
{"page_num": {"$lte": end_page}},
]
}
# Get all documents in the range
results = collection.get(
where=where_filter,
include=["documents", "metadatas"],
)
if not results or not results["documents"]:
return ""
# Sort by page number and chunk order
paired = list(zip(results["documents"], results["metadatas"], results["ids"]))
paired.sort(key=lambda x: (x[1].get("page_num", 0), x[2]))
# Deduplicate overlapping chunks per page
seen_pages = {}
for doc, meta, doc_id in paired:
page = meta.get("page_num", 0)
if page not in seen_pages:
seen_pages[page] = []
seen_pages[page].append(doc)
texts = []
for page in sorted(seen_pages.keys()):
# Join chunks for each page, removing overlap duplicates
page_text = seen_pages[page][0]
for chunk in seen_pages[page][1:]:
# Find overlap and append only new content
overlap_len = 200
if len(chunk) > overlap_len:
page_text += chunk[overlap_len:]
else:
page_text += chunk
texts.append(f"--- Page {page} ---\n{page_text}")
return "\n\n".join(texts)

View file

@ -0,0 +1,12 @@
from celery import Celery
from app.config import settings
celery_app = Celery(
"quiz_tasks",
broker=settings.REDIS_URL,
backend=settings.REDIS_URL,
)
celery_app.conf.task_serializer = "json"
celery_app.conf.result_serializer = "json"
celery_app.conf.accept_content = ["json"]

View file

@ -0,0 +1,53 @@
import logging
from app.tasks import celery_app
from app.database import SessionLocal
from app.models.pdf_document import PDFDocument
from app.services import pdf_service, vector_service
logger = logging.getLogger(__name__)
@celery_app.task(name="process_pdf")
def process_pdf(document_id: int, file_path: str):
"""Background task: extract PDF text and store in ChromaDB."""
db = SessionLocal()
try:
doc = db.query(PDFDocument).filter(PDFDocument.id == document_id).first()
if not doc:
logger.error(f"Document {document_id} not found")
return
# Get page count
total_pages = pdf_service.get_page_count(file_path)
doc.total_pages = total_pages
db.commit()
# Extract text from all pages
pages = pdf_service.extract_text_by_page(file_path)
if not pages:
doc.status = "error"
doc.error_message = "No text could be extracted from the PDF"
db.commit()
return
# Store in ChromaDB
vector_service.store_pages(document_id, pages)
# Mark as ready
doc.status = "ready"
db.commit()
logger.info(f"Document {document_id} processed: {total_pages} pages, {len(pages)} with text")
except Exception as e:
logger.exception(f"Error processing document {document_id}")
try:
doc = db.query(PDFDocument).filter(PDFDocument.id == document_id).first()
if doc:
doc.status = "error"
doc.error_message = str(e)[:500]
db.commit()
except Exception:
pass
finally:
db.close()

View file

64
backend/app/utils/auth.py Normal file
View file

@ -0,0 +1,64 @@
from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.user import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
email: str = payload.get("sub")
if email is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.email == email).first()
if user is None:
raise credentials_exception
return user
def require_admin(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
return current_user
def require_moderator(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_moderator:
raise HTTPException(status_code=403, detail="Moderator access required")
return current_user

View file

@ -0,0 +1,82 @@
import asyncio
import logging
from datetime import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from app.database import SessionLocal
from app.models.reminder import ReminderSchedule
from app.models.user import User
from app.models.quiz import Quiz
logger = logging.getLogger(__name__)
scheduler = BackgroundScheduler()
def check_and_send_reminders():
"""Check for due reminders and send emails."""
db = SessionLocal()
try:
due_reminders = db.query(ReminderSchedule).filter(
ReminderSchedule.is_active == True,
ReminderSchedule.next_reminder_at <= datetime.utcnow(),
).all()
if not due_reminders:
return
logger.info(f"Found {len(due_reminders)} due reminders")
for reminder in due_reminders:
user = db.query(User).filter(User.id == reminder.user_id).first()
quiz = db.query(Quiz).filter(Quiz.id == reminder.quiz_id).first()
if not user or not quiz:
reminder.is_active = False
continue
# Send email asynchronously
try:
from app.services.email_service import send_reminder_email
loop = asyncio.new_event_loop()
loop.run_until_complete(
send_reminder_email(
email=user.email,
user_name=user.name,
quiz_title=quiz.title,
score=reminder.performance_score,
next_date=reminder.next_reminder_at.strftime("%Y-%m-%d"),
)
)
loop.close()
except Exception as e:
logger.error(f"Failed to send reminder {reminder.id}: {e}")
# Schedule next reminder
from datetime import timedelta
reminder.next_reminder_at = datetime.utcnow() + timedelta(days=reminder.interval_days)
db.commit()
except Exception as e:
logger.exception(f"Scheduler error: {e}")
finally:
db.close()
def start_scheduler():
"""Start the APScheduler with daily reminder check."""
scheduler.add_job(
check_and_send_reminders,
"interval",
hours=24,
id="reminder_check",
replace_existing=True,
)
scheduler.start()
logger.info("Scheduler started — reminder check runs every 24 hours")
def stop_scheduler():
if scheduler.running:
scheduler.shutdown()

19
backend/requirements.txt Normal file
View file

@ -0,0 +1,19 @@
fastapi==0.109.2
uvicorn[standard]==0.27.1
sqlalchemy==2.0.27
alembic==1.13.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.9
pydantic[email]==2.6.1
pydantic-settings==2.1.0
PyMuPDF==1.23.22
litellm==1.27.10
chromadb==0.4.24
celery[redis]==5.3.6
redis==5.0.1
fastapi-mail==1.4.1
apscheduler==3.10.4
aiofiles==23.2.1
python-dotenv==1.0.1
httpx==0.27.0

View file

53
docker-compose.yml Normal file
View file

@ -0,0 +1,53 @@
version: "3.8"
services:
frontend:
build: ./frontend
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped
backend:
build: ./backend
ports:
- "8000:8000"
env_file:
- ./backend/.env
volumes:
- uploads_data:/app/uploads
- chroma_data:/app/chroma_data
- sqlite_data:/app/data
environment:
- DATABASE_URL=sqlite:////app/data/quiz.db
depends_on:
- redis
restart: unless-stopped
celery:
build: ./backend
command: celery -A app.tasks worker --loglevel=info --concurrency=2
env_file:
- ./backend/.env
volumes:
- uploads_data:/app/uploads
- chroma_data:/app/chroma_data
- sqlite_data:/app/data
environment:
- DATABASE_URL=sqlite:////app/data/quiz.db
depends_on:
- redis
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
uploads_data:
chroma_data:
sqlite_data:
redis_data:

2
frontend/.dockerignore Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

13
frontend/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM node:18-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
frontend/index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PDF Quiz Generator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

25
frontend/nginx.conf Normal file
View file

@ -0,0 +1,25 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# API proxy to backend
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Large file uploads
client_max_body_size 500M;
proxy_request_buffering off;
proxy_read_timeout 600s;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}

23
frontend/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "quiz-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.6.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0"
},
"devDependencies": {
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.1.0"
}
}

54
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,54 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import Navbar from './components/Navbar'
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage'
import DashboardPage from './pages/DashboardPage'
import UploadPage from './pages/UploadPage'
import DocumentDetailPage from './pages/DocumentDetailPage'
import QuizPage from './pages/QuizPage'
import QuizzesPage from './pages/QuizzesPage'
import ResultsPage from './pages/ResultsPage'
import AdminPage from './pages/AdminPage'
function ProtectedRoute({ children, requireModerator = false }) {
const { user, loading } = useAuth()
if (loading) return <div className="loading"><div className="spinner"></div></div>
if (!user) return <Navigate to="/login" />
if (requireModerator && user.role !== 'admin' && user.role !== 'moderator') return <Navigate to="/" />
return children
}
function AppRoutes() {
const { user, loading } = useAuth()
if (loading) return <div className="loading"><div className="spinner"></div></div>
return (
<>
<Navbar />
<div className="container">
<Routes>
<Route path="/login" element={user ? <Navigate to="/" /> : <LoginPage />} />
<Route path="/register" element={user ? <Navigate to="/" /> : <RegisterPage />} />
<Route path="/" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
<Route path="/upload" element={<ProtectedRoute requireModerator><UploadPage /></ProtectedRoute>} />
<Route path="/documents/:id" element={<ProtectedRoute><DocumentDetailPage /></ProtectedRoute>} />
<Route path="/quizzes" element={<ProtectedRoute><QuizzesPage /></ProtectedRoute>} />
<Route path="/quizzes/:id" element={<ProtectedRoute><QuizPage /></ProtectedRoute>} />
<Route path="/results/:id" element={<ProtectedRoute><ResultsPage /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
</Routes>
</div>
</>
)
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
)
}

View file

@ -0,0 +1,26 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api

View file

@ -0,0 +1,36 @@
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function Navbar() {
const { user, logout } = useAuth()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
return (
<div className="navbar">
<div className="container">
<Link to="/" className="logo">PDF Quiz</Link>
{user && (
<nav>
<Link to="/">Dashboard</Link>
<Link to="/quizzes">Quizzes</Link>
{isModerator && <Link to="/upload">Upload PDF</Link>}
{user.role === 'admin' && <Link to="/admin" style={{ color: '#fbbf24' }}>Admin</Link>}
<span style={{ color: '#94a3b8', fontSize: '0.85rem' }}>
{user.name}
{user.role !== 'user' && (
<span style={{
marginLeft: 6, fontSize: '0.7rem',
background: user.role === 'admin' ? '#ef4444' : '#7c3aed',
color: 'white', padding: '1px 6px', borderRadius: 10
}}>
{user.role}
</span>
)}
</span>
<button onClick={logout}>Logout</button>
</nav>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,50 @@
import { createContext, useContext, useState, useEffect } from 'react'
import api from '../api/client'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
api.get('/auth/me')
.then(res => setUser(res.data))
.catch(() => localStorage.removeItem('token'))
.finally(() => setLoading(false))
} else {
setLoading(false)
}
}, [])
const login = async (email, password) => {
const res = await api.post('/auth/login', { email, password })
localStorage.setItem('token', res.data.access_token)
const me = await api.get('/auth/me')
setUser(me.data)
return me.data
}
const register = async (email, password, name) => {
const res = await api.post('/auth/register', { email, password, name })
localStorage.setItem('token', res.data.access_token)
const me = await api.get('/auth/me')
setUser(me.data)
return me.data
}
const logout = () => {
localStorage.removeItem('token')
setUser(null)
}
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)

418
frontend/src/index.css Normal file
View file

@ -0,0 +1,418 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8fafc;
color: #1e293b;
line-height: 1.6;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 0 20px;
}
/* Navigation */
.navbar {
background: #1e293b;
color: white;
padding: 12px 0;
margin-bottom: 24px;
}
.navbar .container {
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar .logo {
font-size: 1.3rem;
font-weight: 700;
color: #60a5fa;
text-decoration: none;
}
.navbar nav {
display: flex;
gap: 16px;
align-items: center;
}
.navbar a {
color: #cbd5e1;
text-decoration: none;
font-size: 0.9rem;
}
.navbar a:hover {
color: white;
}
.navbar button {
background: transparent;
border: 1px solid #475569;
color: #cbd5e1;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
/* Cards */
.card {
background: white;
border-radius: 10px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
margin-bottom: 16px;
}
.card h2 {
margin-bottom: 16px;
font-size: 1.2rem;
}
/* Buttons */
.btn {
display: inline-block;
padding: 10px 20px;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
transition: background 0.2s;
}
.btn-primary {
background: #2563eb;
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-secondary {
background: #e2e8f0;
color: #334155;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-sm {
padding: 6px 14px;
font-size: 0.82rem;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Forms */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: 6px;
font-size: 0.9rem;
}
.form-group input,
.form-group select {
width: 100%;
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.9rem;
}
.form-group input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
}
/* Status badges */
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-processing { background: #fef3c7; color: #92400e; }
.badge-ready { background: #d1fae5; color: #065f46; }
.badge-error { background: #fee2e2; color: #991b1b; }
/* Quiz question styles */
.question-card {
background: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 16px;
border-left: 4px solid #2563eb;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.question-card h3 {
font-size: 1rem;
margin-bottom: 12px;
}
.question-card .options {
display: flex;
flex-direction: column;
gap: 8px;
}
.question-card .option {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
}
.question-card .option:hover {
background: #f1f5f9;
}
.question-card .option.selected {
border-color: #2563eb;
background: #eff6ff;
}
.question-card .option.correct {
border-color: #22c55e;
background: #f0fdf4;
}
.question-card .option.incorrect {
border-color: #ef4444;
background: #fef2f2;
}
.question-card input[type="text"] {
width: 100%;
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
}
.explanation {
margin-top: 12px;
padding: 12px;
background: #f8fafc;
border-radius: 8px;
font-size: 0.85rem;
color: #475569;
border-left: 3px solid #60a5fa;
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
text-align: center;
}
.stat-card .stat-value {
font-size: 2rem;
font-weight: 700;
color: #2563eb;
}
.stat-card .stat-label {
font-size: 0.85rem;
color: #64748b;
margin-top: 4px;
}
/* Grid layouts */
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* Alerts */
.alert {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 0.9rem;
}
.alert-error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.alert-success {
background: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}
/* Score display */
.score-display {
text-align: center;
padding: 32px;
}
.score-display .score-value {
font-size: 3.5rem;
font-weight: 800;
}
.score-display .score-value.good { color: #22c55e; }
.score-display .score-value.ok { color: #f59e0b; }
.score-display .score-value.poor { color: #ef4444; }
/* Upload area */
.upload-area {
border: 2px dashed #cbd5e1;
border-radius: 12px;
padding: 48px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.upload-area:hover {
border-color: #2563eb;
background: #f8fafc;
}
.upload-area.dragging {
border-color: #2563eb;
background: #eff6ff;
}
/* Progress bar */
.progress-bar {
background: #e2e8f0;
border-radius: 999px;
height: 8px;
overflow: hidden;
margin-top: 12px;
}
.progress-bar .fill {
background: #2563eb;
height: 100%;
border-radius: 999px;
transition: width 0.3s;
}
/* Spinner */
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #e2e8f0;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Section list */
.section-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 8px;
}
/* Auth pages */
.auth-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.auth-card {
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
width: 100%;
max-width: 420px;
}
.auth-card h1 {
text-align: center;
margin-bottom: 24px;
color: #1e293b;
}
.auth-card .auth-link {
text-align: center;
margin-top: 16px;
font-size: 0.9rem;
}
.auth-card .auth-link a {
color: #2563eb;
text-decoration: none;
}
/* Loading */
.loading {
text-align: center;
padding: 48px;
color: #64748b;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 48px;
color: #94a3b8;
}
@media (max-width: 768px) {
.grid-2 {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr 1fr;
}
}

10
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View file

@ -0,0 +1,249 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
const TASKS = ['extraction', 'tts', 'general']
const PRESET_MODELS = {
extraction: [
{ name: 'GPT-4o Mini', model_id: 'gpt-4o-mini' },
{ name: 'GPT-4o', model_id: 'gpt-4o' },
{ name: 'Claude Sonnet 4.6', model_id: 'anthropic/claude-sonnet-4-6-20250514' },
{ name: 'Claude Haiku 4.5', model_id: 'anthropic/claude-haiku-4-5-20251001' },
{ name: 'Gemini Pro', model_id: 'gemini/gemini-1.5-pro' },
],
tts: [
{ name: 'Google Vertex TTS', model_id: 'vertex_ai/google/cloud-tts' },
{ name: 'OpenAI TTS-1', model_id: 'openai/tts-1' },
{ name: 'OpenAI TTS-1 HD', model_id: 'openai/tts-1-hd' },
],
general: [
{ name: 'GPT-4o Mini', model_id: 'gpt-4o-mini' },
{ name: 'GPT-4o', model_id: 'gpt-4o' },
],
}
export default function AdminPage() {
const { user } = useAuth()
const navigate = useNavigate()
const [tab, setTab] = useState('models')
const [users, setUsers] = useState([])
const [models, setModels] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false })
useEffect(() => {
if (!user?.role || user.role !== 'admin') { navigate('/'); return }
loadData()
}, [user])
const loadData = async () => {
setLoading(true)
try {
const [usersRes, modelsRes] = await Promise.all([
api.get('/admin/users'),
api.get('/admin/models'),
])
setUsers(usersRes.data)
setModels(modelsRes.data)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to load data')
} finally {
setLoading(false)
}
}
const updateRole = async (userId, role) => {
try {
await api.put(`/admin/users/${userId}/role`, { role })
setSuccess(`Role updated to ${role}`)
loadData()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update role')
}
}
const createModel = async (e) => {
e.preventDefault()
setError('')
try {
await api.post('/admin/models', newModel)
setSuccess('Model added')
setNewModel({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false })
loadData()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to add model')
}
}
const setDefault = async (modelId) => {
try {
await api.put(`/admin/models/${modelId}`, { is_default: true })
setSuccess('Default model updated')
loadData()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update')
}
}
const toggleActive = async (model) => {
try {
await api.put(`/admin/models/${model.id}`, { is_active: !model.is_active })
loadData()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update')
}
}
const deleteModel = async (modelId) => {
if (!confirm('Delete this model config?')) return
try {
await api.delete(`/admin/models/${modelId}`)
loadData()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to delete')
}
}
const applyPreset = (preset) => {
setNewModel(m => ({ ...m, name: preset.name, model_id: preset.model_id }))
}
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
const modelsByTask = TASKS.reduce((acc, t) => {
acc[t] = models.filter(m => m.task === t)
return acc
}, {})
return (
<div>
<div className="card">
<h2>Admin Dashboard</h2>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
{['models', 'users'].map(t => (
<button key={t} className={`btn ${tab === t ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t)}>
{t === 'models' ? 'AI Models' : 'Users'}
</button>
))}
</div>
</div>
{error && <div className="alert alert-error">{error}</div>}
{success && <div className="alert alert-success">{success}</div>}
{tab === 'models' && (
<>
{TASKS.map(task => (
<div className="card" key={task}>
<h2 style={{ textTransform: 'capitalize', marginBottom: 12 }}>
{task} Models
<span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}>
{task === 'extraction' ? '— AI that extracts questions from PDFs' :
task === 'tts' ? '— Text-to-speech for reading questions' :
'— General purpose AI'}
</span>
</h2>
{modelsByTask[task].length === 0 ? (
<div className="empty-state" style={{ padding: 16 }}>No models configured</div>
) : (
modelsByTask[task].map(m => (
<div className="section-item" key={m.id}>
<div>
<strong>{m.name}</strong>
<div style={{ fontSize: '0.82rem', color: '#64748b', fontFamily: 'monospace' }}>{m.model_id}</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{m.is_default && <span className="badge badge-ready">Default</span>}
{!m.is_default && (
<button className="btn btn-secondary btn-sm" onClick={() => setDefault(m.id)}>Set Default</button>
)}
<button
className={`btn btn-sm ${m.is_active ? 'btn-secondary' : 'btn-primary'}`}
onClick={() => toggleActive(m)}
>{m.is_active ? 'Disable' : 'Enable'}</button>
<button className="btn btn-danger btn-sm" onClick={() => deleteModel(m.id)}>Remove</button>
</div>
</div>
))
)}
</div>
))}
<div className="card">
<h2>Add Model</h2>
<form onSubmit={createModel}>
<div className="grid-2">
<div className="form-group">
<label>Task</label>
<select value={newModel.task} onChange={e => setNewModel(m => ({ ...m, task: e.target.value }))}>
{TASKS.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div className="form-group">
<label>Preset (optional)</label>
<select onChange={e => { if (e.target.value) applyPreset(JSON.parse(e.target.value)) }}>
<option value=""> select preset </option>
{(PRESET_MODELS[newModel.task] || []).map(p => (
<option key={p.model_id} value={JSON.stringify(p)}>{p.name}</option>
))}
</select>
</div>
<div className="form-group">
<label>Display Name</label>
<input type="text" value={newModel.name} onChange={e => setNewModel(m => ({ ...m, name: e.target.value }))} required />
</div>
<div className="form-group">
<label>Model ID (LiteLLM format)</label>
<input type="text" value={newModel.model_id} onChange={e => setNewModel(m => ({ ...m, model_id: e.target.value }))} placeholder="e.g. gpt-4o-mini" required />
</div>
<div className="form-group">
<label>API Key (optional override)</label>
<input type="password" value={newModel.api_key} onChange={e => setNewModel(m => ({ ...m, api_key: e.target.value }))} placeholder="Leave blank to use .env key" />
</div>
<div className="form-group" style={{ display: 'flex', gap: 16, alignItems: 'center', marginTop: 24 }}>
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={newModel.is_default} onChange={e => setNewModel(m => ({ ...m, is_default: e.target.checked }))} />
Set as default
</label>
</div>
</div>
<button className="btn btn-primary" type="submit">Add Model</button>
</form>
</div>
</>
)}
{tab === 'users' && (
<div className="card">
<h2>Users</h2>
{users.map(u => (
<div className="section-item" key={u.id}>
<div>
<strong>{u.name}</strong>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>{u.email} · joined {new Date(u.created_at).toLocaleDateString()}</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span className={`badge ${u.role === 'admin' ? 'badge-error' : u.role === 'moderator' ? 'badge-processing' : 'badge-ready'}`}>
{u.role}
</span>
<select
value={u.role}
onChange={e => updateRole(u.id, e.target.value)}
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem' }}
>
<option value="user">user</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
</div>
</div>
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,96 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
export default function DashboardPage() {
const [documents, setDocuments] = useState([])
const [stats, setStats] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
api.get('/documents/'),
api.get('/attempts/stats/dashboard'),
]).then(([docsRes, statsRes]) => {
setDocuments(docsRes.data)
setStats(statsRes.data)
}).catch(console.error)
.finally(() => setLoading(false))
}, [])
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
return (
<div>
{stats && (
<div className="stats-grid">
<div className="stat-card">
<div className="stat-value">{stats.total_documents}</div>
<div className="stat-label">Documents</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.total_quizzes}</div>
<div className="stat-label">Quizzes</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.total_attempts}</div>
<div className="stat-label">Attempts</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.average_score}%</div>
<div className="stat-label">Avg Score</div>
</div>
</div>
)}
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2>Your Documents</h2>
<Link to="/upload" className="btn btn-primary">Upload PDF</Link>
</div>
{documents.length === 0 ? (
<div className="empty-state">
<p>No documents yet. Upload a PDF to get started!</p>
</div>
) : (
documents.map(doc => (
<Link to={`/documents/${doc.id}`} key={doc.id} style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="section-item">
<div>
<strong>{doc.original_filename}</strong>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
{doc.total_pages ? `${doc.total_pages} pages` : 'Processing...'} | {new Date(doc.uploaded_at).toLocaleDateString()}
</div>
</div>
<span className={`badge badge-${doc.status}`}>{doc.status}</span>
</div>
</Link>
))
)}
</div>
{stats && stats.quiz_stats.length > 0 && (
<div className="card">
<h2>Quiz Performance</h2>
{stats.quiz_stats.map(qs => (
<div className="section-item" key={qs.quiz_id}>
<div>
<strong>{qs.quiz_title}</strong>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
{qs.attempts_count} attempts | Best: {qs.best_score}%
</div>
</div>
<span style={{
fontWeight: 700,
color: qs.latest_score >= 70 ? '#22c55e' : qs.latest_score >= 50 ? '#f59e0b' : '#ef4444'
}}>
{qs.latest_score}%
</span>
</div>
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,221 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
export default function DocumentDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const { user } = useAuth()
const [doc, setDoc] = useState(null)
const [loading, setLoading] = useState(true)
const [sectionForm, setSectionForm] = useState({ name: '', start_page: 1, end_page: 10 })
const [creating, setCreating] = useState(false)
const [generating, setGenerating] = useState(null)
const [quizTitle, setQuizTitle] = useState('')
const [quizMode, setQuizMode] = useState('timed')
const [timeLimitMinutes, setTimeLimitMinutes] = useState('')
const [error, setError] = useState('')
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const fetchDoc = () => {
api.get(`/documents/${id}`).then(res => {
setDoc(res.data)
setLoading(false)
}).catch(() => navigate('/'))
}
useEffect(() => { fetchDoc() }, [id])
useEffect(() => {
if (!doc || doc.status !== 'processing') return
const interval = setInterval(() => {
api.get(`/documents/${id}/status`).then(res => {
if (res.data.status !== 'processing') {
fetchDoc()
clearInterval(interval)
}
})
}, 3000)
return () => clearInterval(interval)
}, [doc?.status])
const createSection = async (e) => {
e.preventDefault()
setCreating(true)
setError('')
try {
await api.post(`/documents/${id}/sections`, sectionForm)
fetchDoc()
setSectionForm({ name: '', start_page: 1, end_page: 10 })
} catch (err) {
setError(err.response?.data?.detail || 'Failed to create section')
} finally {
setCreating(false)
}
}
const generateQuiz = async (sectionId, sectionName) => {
setGenerating(sectionId)
setError('')
try {
const title = quizTitle || `Quiz: ${sectionName}`
const res = await api.post('/quizzes/', {
section_id: sectionId,
title,
mode: quizMode,
time_limit_minutes: quizMode === 'timed' && timeLimitMinutes ? parseInt(timeLimitMinutes) : null,
})
navigate(`/quizzes/${res.data.id}`)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to generate quiz. Check AI model config.')
setGenerating(null)
}
}
const deleteDoc = async () => {
if (!confirm('Delete this document and all its quizzes?')) return
await api.delete(`/documents/${id}`)
navigate('/')
}
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
if (!doc) return null
return (
<div>
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h2>{doc.original_filename}</h2>
<p style={{ color: '#64748b', fontSize: '0.9rem' }}>
{doc.total_pages ? `${doc.total_pages} pages` : ''} · Uploaded {new Date(doc.uploaded_at).toLocaleDateString()}
</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span className={`badge badge-${doc.status}`}>{doc.status}</span>
{isModerator && <button className="btn btn-danger btn-sm" onClick={deleteDoc}>Delete</button>}
</div>
</div>
{doc.status === 'processing' && (
<div style={{ marginTop: 16, textAlign: 'center', color: '#64748b' }}>
<div className="spinner"></div>
<p style={{ marginTop: 8 }}>Processing PDF and extracting text... This may take a while for large files.</p>
</div>
)}
{doc.status === 'error' && (
<div className="alert alert-error" style={{ marginTop: 16 }}>
Error: {doc.error_message || 'Unknown error'}
</div>
)}
</div>
{doc.status === 'ready' && (
<>
{error && <div className="alert alert-error">{error}</div>}
{/* Moderators can create sections */}
{isModerator && (
<div className="card">
<h2>Create Section</h2>
<p style={{ color: '#64748b', marginBottom: 16, fontSize: '0.9rem' }}>
Define page ranges the AI will extract all questions from those pages.
</p>
<form onSubmit={createSection}>
<div className="grid-2">
<div className="form-group">
<label>Section Name</label>
<input
type="text"
value={sectionForm.name}
onChange={e => setSectionForm({ ...sectionForm, name: e.target.value })}
placeholder="e.g., Chapter 1 (pages 150)"
required
/>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<div className="form-group" style={{ flex: 1 }}>
<label>Start Page</label>
<input type="number" min={1} max={doc.total_pages || 9999} value={sectionForm.start_page}
onChange={e => setSectionForm({ ...sectionForm, start_page: parseInt(e.target.value) || 1 })} />
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>End Page</label>
<input type="number" min={1} max={doc.total_pages || 9999} value={sectionForm.end_page}
onChange={e => setSectionForm({ ...sectionForm, end_page: parseInt(e.target.value) || 10 })} />
</div>
</div>
</div>
<button className="btn btn-primary" disabled={creating}>
{creating ? 'Creating...' : 'Create Section'}
</button>
</form>
</div>
)}
{/* Sections list */}
{doc.sections && doc.sections.length > 0 && (
<div className="card">
<h2>Sections</h2>
{/* Quiz settings (moderator only) */}
{isModerator && (
<div style={{ background: '#f8fafc', padding: 16, borderRadius: 8, marginBottom: 16 }}>
<strong style={{ display: 'block', marginBottom: 12 }}>Quiz Settings</strong>
<div className="grid-2">
<div className="form-group">
<label>Quiz Title (optional)</label>
<input type="text" value={quizTitle} onChange={e => setQuizTitle(e.target.value)}
placeholder="Auto-generated if empty" />
</div>
<div className="form-group">
<label>Mode</label>
<select value={quizMode} onChange={e => setQuizMode(e.target.value)}>
<option value="timed">Timed answers hidden, timer counts down</option>
<option value="learning">Learning answers + explanations shown inline</option>
</select>
</div>
{quizMode === 'timed' && (
<div className="form-group">
<label>Time Limit (minutes, optional)</label>
<input type="number" min={1} value={timeLimitMinutes}
onChange={e => setTimeLimitMinutes(e.target.value)}
placeholder="Leave blank for no limit" />
</div>
)}
</div>
</div>
)}
{doc.sections.map(section => (
<div className="section-item" key={section.id}>
<div>
<strong>{section.name}</strong>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
Pages {section.start_page}{section.end_page}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{isModerator && (
<button
className="btn btn-primary btn-sm"
onClick={() => generateQuiz(section.id, section.name)}
disabled={generating === section.id}
>
{generating === section.id ? (
<><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</>
) : 'Extract & Create Quiz'}
</button>
)}
</div>
</div>
))}
</div>
)}
</>
)}
</div>
)
}

View file

@ -0,0 +1,51 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(email, password)
navigate('/')
} catch (err) {
setError(err.response?.data?.detail || 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="auth-page">
<div className="auth-card">
<h1>Sign In</h1>
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
</div>
<div className="form-group">
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="auth-link">
Don't have an account? <Link to="/register">Sign up</Link>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,299 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '../api/client'
function TTSButton({ text }) {
const [playing, setPlaying] = useState(false)
const audioRef = useRef(null)
const speak = async () => {
if (playing) {
audioRef.current?.pause()
setPlaying(false)
return
}
try {
setPlaying(true)
const res = await api.post('/tts/speak', { text }, { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const audio = new Audio(url)
audioRef.current = audio
audio.onended = () => { setPlaying(false); URL.revokeObjectURL(url) }
audio.onerror = () => setPlaying(false)
audio.play()
} catch {
setPlaying(false)
}
}
return (
<button
onClick={speak}
title={playing ? 'Stop' : 'Read aloud'}
style={{
background: playing ? '#ef4444' : '#e0e7ff',
color: playing ? 'white' : '#3730a3',
border: 'none',
borderRadius: '6px',
padding: '4px 10px',
cursor: 'pointer',
fontSize: '0.8rem',
marginLeft: 8,
}}
>
{playing ? '⏹ Stop' : '🔊 Listen'}
</button>
)
}
function TimerDisplay({ seconds, total }) {
const pct = total > 0 ? (seconds / total) * 100 : 100
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
const color = pct > 50 ? '#22c55e' : pct > 20 ? '#f59e0b' : '#ef4444'
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ fontWeight: 700, fontSize: '1.1rem', color }}>
{String(mins).padStart(2, '0')}:{String(secs).padStart(2, '0')}
</div>
<div style={{ width: 100, background: '#e2e8f0', borderRadius: 999, height: 6 }}>
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 999, transition: 'width 1s' }} />
</div>
</div>
)
}
export default function QuizPage() {
const { id } = useParams()
const navigate = useNavigate()
const [quiz, setQuiz] = useState(null)
const [answers, setAnswers] = useState({})
const [currentIdx, setCurrentIdx] = useState(0)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [attemptId, setAttemptId] = useState(null)
const [timeLeft, setTimeLeft] = useState(null)
const [totalTime, setTotalTime] = useState(null)
const timerRef = useRef(null)
useEffect(() => {
const load = async () => {
try {
const quizRes = await api.get(`/quizzes/${id}`)
setQuiz(quizRes.data)
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
setAttemptId(attemptRes.data.id)
if (quizRes.data.mode === 'timed' && quizRes.data.time_limit_minutes) {
const secs = quizRes.data.time_limit_minutes * 60
setTimeLeft(secs)
setTotalTime(secs)
}
} catch {
navigate('/')
} finally {
setLoading(false)
}
}
load()
return () => clearInterval(timerRef.current)
}, [id])
// Timer countdown
useEffect(() => {
if (timeLeft === null) return
if (timeLeft <= 0) {
handleSubmit(true)
return
}
timerRef.current = setInterval(() => {
setTimeLeft(t => {
if (t <= 1) { clearInterval(timerRef.current); return 0 }
return t - 1
})
}, 1000)
return () => clearInterval(timerRef.current)
}, [timeLeft === null])
const setAnswer = (questionId, value) => {
setAnswers(prev => ({ ...prev, [questionId]: value }))
}
const handleSubmit = useCallback(async (autoSubmit = false) => {
if (!attemptId || submitting) return
clearInterval(timerRef.current)
setSubmitting(true)
try {
const submission = {
answers: Object.entries(answers).map(([qid, answer]) => ({
question_id: parseInt(qid),
user_answer: answer,
})),
}
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
navigate(`/results/${attemptId}`, { state: { result: res.data } })
} catch (err) {
if (!autoSubmit) alert(err.response?.data?.detail || 'Submission failed')
} finally {
setSubmitting(false)
}
}, [attemptId, answers, submitting])
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
if (!quiz) return null
const questions = quiz.questions || []
const current = questions[currentIdx]
const isLearning = quiz.mode === 'learning'
const answeredCount = Object.keys(answers).length
const totalCount = questions.length
const isLast = currentIdx === totalCount - 1
return (
<div>
{/* Header */}
<div className="card" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div>
<h2 style={{ marginBottom: 2 }}>{quiz.title}</h2>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
<span style={{
background: isLearning ? '#d1fae5' : '#e0e7ff',
color: isLearning ? '#065f46' : '#3730a3',
padding: '2px 8px', borderRadius: 12, fontWeight: 600, marginRight: 8
}}>
{isLearning ? 'Learning Mode' : 'Timed Mode'}
</span>
Question {currentIdx + 1} of {totalCount} · {answeredCount} answered
</div>
</div>
{timeLeft !== null && (
<TimerDisplay seconds={timeLeft} total={totalTime} />
)}
</div>
{/* Progress bar */}
<div className="progress-bar" style={{ marginBottom: 20 }}>
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
</div>
{/* Current question */}
{current && (
<div className="question-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<h3 style={{ flex: 1 }}>
Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}
</h3>
<TTSButton text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`} />
</div>
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '8px 0', display: 'inline-block' }}>
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
</span>
{/* Question image */}
{current.image_path && (
<div style={{ margin: '12px 0' }}>
<img
src={`/uploads/${current.image_path}`}
alt="Question illustration"
style={{ maxWidth: '100%', maxHeight: 300, borderRadius: 8, border: '1px solid #e2e8f0' }}
onError={e => e.target.style.display = 'none'}
/>
</div>
)}
{/* Answer options */}
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
<div className="options" style={{ marginTop: 12 }}>
{current.options.map((opt, i) => {
const isSelected = answers[current.id] === opt
const isCorrect = isLearning && opt === current.correct_answer
const isWrong = isLearning && isSelected && opt !== current.correct_answer
return (
<div
key={i}
className={`option ${isSelected ? 'selected' : ''} ${isCorrect && isSelected ? 'correct' : ''} ${isWrong ? 'incorrect' : ''}`}
onClick={() => setAnswer(current.id, opt)}
>
<input type="radio" name={`q-${current.id}`} checked={isSelected} onChange={() => setAnswer(current.id, opt)} />
<span>{opt}</span>
{isLearning && isCorrect && <span style={{ marginLeft: 'auto', color: '#22c55e', fontWeight: 600 }}> Correct</span>}
</div>
)
})}
</div>
) : (
<input
type="text"
placeholder="Type your answer..."
value={answers[current.id] || ''}
onChange={e => setAnswer(current.id, e.target.value)}
style={{ marginTop: 12, width: '100%', padding: '10px 14px', border: '1px solid #d1d5db', borderRadius: 8 }}
/>
)}
{/* Learning mode: show explanation after answering */}
{isLearning && answers[current.id] && current.explanation && (
<div className="explanation" style={{ marginTop: 16 }}>
<strong>Explanation:</strong> {current.explanation}
</div>
)}
{/* Learning mode: show correct answer for fill blank */}
{isLearning && current.question_type === 'fill_blank' && answers[current.id] && (
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
<strong>Answer:</strong> {current.correct_answer}
</div>
)}
</div>
)}
{/* Navigation */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16 }}>
<button
className="btn btn-secondary"
onClick={() => setCurrentIdx(i => Math.max(0, i - 1))}
disabled={currentIdx === 0}
> Previous</button>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
{questions.map((q, i) => (
<button
key={q.id}
onClick={() => setCurrentIdx(i)}
style={{
width: 32, height: 32, borderRadius: '50%', border: 'none', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600,
background: i === currentIdx ? '#2563eb' : answers[q.id] ? '#d1fae5' : '#e2e8f0',
color: i === currentIdx ? 'white' : answers[q.id] ? '#065f46' : '#475569',
}}
>{i + 1}</button>
))}
</div>
{isLast ? (
<button
className="btn btn-primary"
onClick={() => handleSubmit(false)}
disabled={submitting}
>
{submitting ? 'Submitting...' : 'Submit Quiz'}
</button>
) : (
<button
className="btn btn-primary"
onClick={() => setCurrentIdx(i => Math.min(totalCount - 1, i + 1))}
>Next </button>
)}
</div>
{answeredCount > 0 && !isLast && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button className="btn btn-secondary" onClick={() => handleSubmit(false)} disabled={submitting}>
Submit now ({answeredCount}/{totalCount} answered)
</button>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,43 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
export default function QuizzesPage() {
const [quizzes, setQuizzes] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
api.get('/quizzes/').then(res => setQuizzes(res.data))
.catch(console.error)
.finally(() => setLoading(false))
}, [])
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
return (
<div>
<div className="card">
<h2>Your Quizzes</h2>
{quizzes.length === 0 ? (
<div className="empty-state">
<p>No quizzes yet. Upload a PDF and generate quizzes from sections.</p>
</div>
) : (
quizzes.map(quiz => (
<div className="section-item" key={quiz.id}>
<div>
<strong>{quiz.title}</strong>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
{quiz.questions_count} questions | {new Date(quiz.created_at).toLocaleDateString()}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Link to={`/quizzes/${quiz.id}`} className="btn btn-primary btn-sm">Take Quiz</Link>
</div>
</div>
))
)}
</div>
</div>
)
}

View file

@ -0,0 +1,56 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function RegisterPage() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { register } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await register(email, password, name)
navigate('/')
} catch (err) {
setError(err.response?.data?.detail || 'Registration failed')
} finally {
setLoading(false)
}
}
return (
<div className="auth-page">
<div className="auth-card">
<h1>Create Account</h1>
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name</label>
<input type="text" value={name} onChange={e => setName(e.target.value)} required />
</div>
<div className="form-group">
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
</div>
<div className="form-group">
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={6} />
</div>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Creating account...' : 'Sign Up'}
</button>
</form>
<div className="auth-link">
Already have an account? <Link to="/login">Sign in</Link>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,75 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'
import api from '../api/client'
export default function ResultsPage() {
const { id } = useParams()
const location = useLocation()
const navigate = useNavigate()
const [result, setResult] = useState(location.state?.result || null)
const [loading, setLoading] = useState(!result)
useEffect(() => {
if (!result) {
api.get(`/attempts/${id}`)
.then(res => setResult(res.data))
.catch(() => navigate('/'))
.finally(() => setLoading(false))
}
}, [id])
if (loading) return <div className="loading"><div className="spinner"></div> Loading results...</div>
if (!result) return null
const scoreClass = result.percentage >= 70 ? 'good' : result.percentage >= 50 ? 'ok' : 'poor'
return (
<div>
<div className="card">
<div className="score-display">
<div className={`score-value ${scoreClass}`}>{result.percentage}%</div>
<div style={{ fontSize: '1.1rem', color: '#64748b', marginTop: 8 }}>
{result.score} out of {result.total_questions} correct
</div>
<div style={{ marginTop: 16, color: '#475569' }}>
{result.percentage >= 90 ? 'Excellent! You\'ve mastered this material.' :
result.percentage >= 70 ? 'Good job! A few more reviews and you\'ll nail it.' :
result.percentage >= 50 ? 'Getting there! Keep practicing.' :
'Don\'t worry — review the explanations below and try again!'}
</div>
<div style={{ marginTop: 20, display: 'flex', gap: 12, justifyContent: 'center' }}>
<Link to={`/quizzes/${result.quiz_id}`} className="btn btn-primary">Retake Quiz</Link>
<Link to="/" className="btn btn-secondary">Dashboard</Link>
</div>
</div>
</div>
<h2 style={{ marginBottom: 16 }}>Question Review</h2>
{result.answers?.map((ans, idx) => (
<div className="question-card" key={idx} style={{
borderLeftColor: ans.is_correct ? '#22c55e' : '#ef4444'
}}>
<h3>Q{idx + 1}. {ans.question_text}</h3>
<div style={{ marginTop: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontWeight: 600, color: ans.is_correct ? '#22c55e' : '#ef4444' }}>
{ans.is_correct ? 'Correct' : 'Incorrect'}
</span>
</div>
<div style={{ fontSize: '0.9rem' }}>
<div><strong>Your answer:</strong> {ans.user_answer || '(no answer)'}</div>
{!ans.is_correct && (
<div style={{ color: '#22c55e' }}><strong>Correct answer:</strong> {ans.correct_answer}</div>
)}
</div>
</div>
{ans.explanation && (
<div className="explanation">
<strong>Explanation:</strong> {ans.explanation}
</div>
)}
</div>
))}
</div>
)
}

View file

@ -0,0 +1,114 @@
import { useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../api/client'
export default function UploadPage() {
const [file, setFile] = useState(null)
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [error, setError] = useState('')
const [dragging, setDragging] = useState(false)
const fileRef = useRef()
const navigate = useNavigate()
const handleFile = (f) => {
if (f && f.type === 'application/pdf') {
setFile(f)
setError('')
} else {
setError('Please select a PDF file')
}
}
const handleUpload = async () => {
if (!file) return
setUploading(true)
setError('')
const formData = new FormData()
formData.append('file', file)
try {
const res = await api.post('/documents/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total) setProgress(Math.round((e.loaded / e.total) * 100))
},
})
navigate(`/documents/${res.data.id}`)
} catch (err) {
setError(err.response?.data?.detail || 'Upload failed')
} finally {
setUploading(false)
}
}
return (
<div>
<div className="card">
<h2>Upload PDF Document</h2>
<p style={{ color: '#64748b', marginBottom: 20 }}>
Upload a PDF file (up to 500MB) to generate interactive quizzes from its content.
</p>
{error && <div className="alert alert-error">{error}</div>}
<div
className={`upload-area ${dragging ? 'dragging' : ''}`}
onClick={() => fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
handleFile(e.dataTransfer.files[0])
}}
>
{file ? (
<div>
<div style={{ fontSize: '1.1rem', fontWeight: 600 }}>{file.name}</div>
<div style={{ color: '#64748b', marginTop: 4 }}>
{(file.size / 1024 / 1024).toFixed(1)} MB
</div>
</div>
) : (
<div>
<div style={{ fontSize: '2rem', marginBottom: 8 }}>PDF</div>
<div>Click or drag a PDF file here</div>
<div style={{ color: '#94a3b8', fontSize: '0.85rem', marginTop: 4 }}>
Supports files up to 500MB
</div>
</div>
)}
</div>
<input
ref={fileRef}
type="file"
accept=".pdf"
hidden
onChange={(e) => handleFile(e.target.files[0])}
/>
{uploading && (
<div className="progress-bar">
<div className="fill" style={{ width: `${progress}%` }}></div>
</div>
)}
<div style={{ marginTop: 20, display: 'flex', gap: 12 }}>
<button
className="btn btn-primary"
onClick={handleUpload}
disabled={!file || uploading}
>
{uploading ? `Uploading ${progress}%...` : 'Upload & Process'}
</button>
<button className="btn btn-secondary" onClick={() => navigate('/')}>
Cancel
</button>
</div>
</div>
</div>
)
}

11
frontend/vite.config.js Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8000'
}
}
})