From b876f13fac2f668c99de5523540640e47767f8e1 Mon Sep 17 00:00:00 2001 From: ifedan-ed Date: Mon, 30 Mar 2026 20:04:53 +0000 Subject: [PATCH] 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 --- .gitignore | 23 + backend/.dockerignore | 8 + backend/.env.example | 28 ++ backend/Dockerfile | 21 + backend/alembic.ini | 116 +++++ backend/alembic/README | 1 + backend/alembic/env.py | 77 ++++ backend/alembic/script.py.mako | 26 ++ .../versions/97ef9b957a02_initial_tables.py | 142 ++++++ ...afcd58735_add_roles_images_modes_models.py | 51 +++ backend/app/__init__.py | 0 backend/app/config.py | 31 ++ backend/app/database.py | 28 ++ backend/app/main.py | 126 ++++++ backend/app/models/__init__.py | 20 + backend/app/models/ai_model_config.py | 18 + backend/app/models/attempt.py | 35 ++ backend/app/models/pdf_document.py | 22 + backend/app/models/question.py | 20 + backend/app/models/quiz.py | 24 + backend/app/models/reminder.py | 23 + backend/app/models/section.py | 17 + backend/app/models/user.py | 30 ++ backend/app/routers/__init__.py | 0 backend/app/routers/admin.py | 119 +++++ backend/app/routers/attempts.py | 236 ++++++++++ backend/app/routers/auth.py | 49 ++ backend/app/routers/documents.py | 180 ++++++++ backend/app/routers/quizzes.py | 109 +++++ backend/app/routers/tts.py | 38 ++ backend/app/schemas/__init__.py | 0 backend/app/schemas/admin.py | 37 ++ backend/app/schemas/attempt.py | 60 +++ backend/app/schemas/auth.py | 34 ++ backend/app/schemas/document.py | 55 +++ backend/app/schemas/quiz.py | 54 +++ backend/app/services/__init__.py | 0 backend/app/services/ai_service.py | 162 +++++++ backend/app/services/email_service.py | 64 +++ backend/app/services/pdf_service.py | 103 +++++ backend/app/services/quiz_service.py | 104 +++++ backend/app/services/reminder_service.py | 61 +++ backend/app/services/vector_service.py | 145 ++++++ backend/app/tasks/__init__.py | 12 + backend/app/tasks/pdf_tasks.py | 53 +++ backend/app/utils/__init__.py | 0 backend/app/utils/auth.py | 64 +++ backend/app/utils/scheduler.py | 82 ++++ backend/requirements.txt | 19 + backend/tests/__init__.py | 0 docker-compose.yml | 53 +++ frontend/.dockerignore | 2 + frontend/Dockerfile | 13 + frontend/index.html | 12 + frontend/nginx.conf | 25 ++ frontend/package.json | 23 + frontend/src/App.jsx | 54 +++ frontend/src/api/client.js | 26 ++ frontend/src/components/Navbar.jsx | 36 ++ frontend/src/context/AuthContext.jsx | 50 +++ frontend/src/index.css | 418 ++++++++++++++++++ frontend/src/main.jsx | 10 + frontend/src/pages/AdminPage.jsx | 249 +++++++++++ frontend/src/pages/DashboardPage.jsx | 96 ++++ frontend/src/pages/DocumentDetailPage.jsx | 221 +++++++++ frontend/src/pages/LoginPage.jsx | 51 +++ frontend/src/pages/QuizPage.jsx | 299 +++++++++++++ frontend/src/pages/QuizzesPage.jsx | 43 ++ frontend/src/pages/RegisterPage.jsx | 56 +++ frontend/src/pages/ResultsPage.jsx | 75 ++++ frontend/src/pages/UploadPage.jsx | 114 +++++ frontend/vite.config.js | 11 + 72 files changed, 4664 insertions(+) create mode 100644 .gitignore create mode 100644 backend/.dockerignore create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/97ef9b957a02_initial_tables.py create mode 100644 backend/alembic/versions/c3aafcd58735_add_roles_images_modes_models.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/ai_model_config.py create mode 100644 backend/app/models/attempt.py create mode 100644 backend/app/models/pdf_document.py create mode 100644 backend/app/models/question.py create mode 100644 backend/app/models/quiz.py create mode 100644 backend/app/models/reminder.py create mode 100644 backend/app/models/section.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/admin.py create mode 100644 backend/app/routers/attempts.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/documents.py create mode 100644 backend/app/routers/quizzes.py create mode 100644 backend/app/routers/tts.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/admin.py create mode 100644 backend/app/schemas/attempt.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/document.py create mode 100644 backend/app/schemas/quiz.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/ai_service.py create mode 100644 backend/app/services/email_service.py create mode 100644 backend/app/services/pdf_service.py create mode 100644 backend/app/services/quiz_service.py create mode 100644 backend/app/services/reminder_service.py create mode 100644 backend/app/services/vector_service.py create mode 100644 backend/app/tasks/__init__.py create mode 100644 backend/app/tasks/pdf_tasks.py create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/app/utils/auth.py create mode 100644 backend/app/utils/scheduler.py create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api/client.js create mode 100644 frontend/src/components/Navbar.jsx create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/AdminPage.jsx create mode 100644 frontend/src/pages/DashboardPage.jsx create mode 100644 frontend/src/pages/DocumentDetailPage.jsx create mode 100644 frontend/src/pages/LoginPage.jsx create mode 100644 frontend/src/pages/QuizPage.jsx create mode 100644 frontend/src/pages/QuizzesPage.jsx create mode 100644 frontend/src/pages/RegisterPage.jsx create mode 100644 frontend/src/pages/ResultsPage.jsx create mode 100644 frontend/src/pages/UploadPage.jsx create mode 100644 frontend/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..112382e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..1f93a81 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +*.db +uploads/ +chroma_data/ +.env +alembic/versions/__pycache__/ diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..4218061 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..ea93741 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..25777f0 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..0075ed4 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/97ef9b957a02_initial_tables.py b/backend/alembic/versions/97ef9b957a02_initial_tables.py new file mode 100644 index 0000000..7b90738 --- /dev/null +++ b/backend/alembic/versions/97ef9b957a02_initial_tables.py @@ -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 ### diff --git a/backend/alembic/versions/c3aafcd58735_add_roles_images_modes_models.py b/backend/alembic/versions/c3aafcd58735_add_roles_images_modes_models.py new file mode 100644 index 0000000..487872e --- /dev/null +++ b/backend/alembic/versions/c3aafcd58735_add_roles_images_modes_models.py @@ -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 ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..98a4fc3 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..3e5b470 --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..9a42180 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..9626e0e --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/ai_model_config.py b/backend/app/models/ai_model_config.py new file mode 100644 index 0000000..beaa87c --- /dev/null +++ b/backend/app/models/ai_model_config.py @@ -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) diff --git a/backend/app/models/attempt.py b/backend/app/models/attempt.py new file mode 100644 index 0000000..4d39567 --- /dev/null +++ b/backend/app/models/attempt.py @@ -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") diff --git a/backend/app/models/pdf_document.py b/backend/app/models/pdf_document.py new file mode 100644 index 0000000..4ace831 --- /dev/null +++ b/backend/app/models/pdf_document.py @@ -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") diff --git a/backend/app/models/question.py b/backend/app/models/question.py new file mode 100644 index 0000000..fca7d3c --- /dev/null +++ b/backend/app/models/question.py @@ -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") diff --git a/backend/app/models/quiz.py b/backend/app/models/quiz.py new file mode 100644 index 0000000..f92ae6c --- /dev/null +++ b/backend/app/models/quiz.py @@ -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") diff --git a/backend/app/models/reminder.py b/backend/app/models/reminder.py new file mode 100644 index 0000000..8009fcb --- /dev/null +++ b/backend/app/models/reminder.py @@ -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") diff --git a/backend/app/models/section.py b/backend/app/models/section.py new file mode 100644 index 0000000..c476c35 --- /dev/null +++ b/backend/app/models/section.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..3855750 --- /dev/null +++ b/backend/app/models/user.py @@ -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") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..1f82004 --- /dev/null +++ b/backend/app/routers/admin.py @@ -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() diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py new file mode 100644 index 0000000..f3126d3 --- /dev/null +++ b/backend/app/routers/attempts.py @@ -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, + ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..0fc48dc --- /dev/null +++ b/backend/app/routers/auth.py @@ -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 diff --git a/backend/app/routers/documents.py b/backend/app/routers/documents.py new file mode 100644 index 0000000..dcfc0fb --- /dev/null +++ b/backend/app/routers/documents.py @@ -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() diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py new file mode 100644 index 0000000..e841721 --- /dev/null +++ b/backend/app/routers/quizzes.py @@ -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() diff --git a/backend/app/routers/tts.py b/backend/app/routers/tts.py new file mode 100644 index 0000000..0f9dc73 --- /dev/null +++ b/backend/app/routers/tts.py @@ -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") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..59480a6 --- /dev/null +++ b/backend/app/schemas/admin.py @@ -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 diff --git a/backend/app/schemas/attempt.py b/backend/app/schemas/attempt.py new file mode 100644 index 0000000..9fe175c --- /dev/null +++ b/backend/app/schemas/attempt.py @@ -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] = [] diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..4cd0add --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py new file mode 100644 index 0000000..c6b021f --- /dev/null +++ b/backend/app/schemas/document.py @@ -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 diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py new file mode 100644 index 0000000..7f722af --- /dev/null +++ b/backend/app/schemas/quiz.py @@ -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] = [] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py new file mode 100644 index 0000000..85ab6ee --- /dev/null +++ b/backend/app/services/ai_service.py @@ -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 diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..1ebadfe --- /dev/null +++ b/backend/app/services/email_service.py @@ -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""" + + +

Quiz Reminder

+

Hi {user_name},

+

It's time to review {quiz_title}!

+

Your last score was {score:.0f}%. + {'Great progress! Keep it up.' if score >= 70 else 'Practice makes perfect — give it another try!'}

+
+

Tip: Spaced repetition helps you remember more over time. + Each review strengthens your memory!

+
+

Log in to take the quiz again.

+

+ You're receiving this because you have active quiz reminders. +

+ + + """ + + 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}") diff --git a/backend/app/services/pdf_service.py b/backend/app/services/pdf_service.py new file mode 100644 index 0000000..8d4e2e2 --- /dev/null +++ b/backend/app/services/pdf_service.py @@ -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 diff --git a/backend/app/services/quiz_service.py b/backend/app/services/quiz_service.py new file mode 100644 index 0000000..ace601b --- /dev/null +++ b/backend/app/services/quiz_service.py @@ -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 diff --git a/backend/app/services/reminder_service.py b/backend/app/services/reminder_service.py new file mode 100644 index 0000000..b18fad3 --- /dev/null +++ b/backend/app/services/reminder_service.py @@ -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() diff --git a/backend/app/services/vector_service.py b/backend/app/services/vector_service.py new file mode 100644 index 0000000..a898480 --- /dev/null +++ b/backend/app/services/vector_service.py @@ -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) diff --git a/backend/app/tasks/__init__.py b/backend/app/tasks/__init__.py new file mode 100644 index 0000000..2ad6d1b --- /dev/null +++ b/backend/app/tasks/__init__.py @@ -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"] diff --git a/backend/app/tasks/pdf_tasks.py b/backend/app/tasks/pdf_tasks.py new file mode 100644 index 0000000..e3e8545 --- /dev/null +++ b/backend/app/tasks/pdf_tasks.py @@ -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() diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py new file mode 100644 index 0000000..8bf63d3 --- /dev/null +++ b/backend/app/utils/auth.py @@ -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 diff --git a/backend/app/utils/scheduler.py b/backend/app/utils/scheduler.py new file mode 100644 index 0000000..54c5474 --- /dev/null +++ b/backend/app/utils/scheduler.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9b35a0c --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ae81953 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..940a44f --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7d9671c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + PDF Quiz Generator + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..69603da --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c837b13 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..535118f --- /dev/null +++ b/frontend/src/App.jsx @@ -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
+ if (!user) return + if (requireModerator && user.role !== 'admin' && user.role !== 'moderator') return + return children +} + +function AppRoutes() { + const { user, loading } = useAuth() + if (loading) return
+ + return ( + <> + +
+ + : } /> + : } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ + ) +} + +export default function App() { + return ( + + + + + + ) +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..367ffe8 --- /dev/null +++ b/frontend/src/api/client.js @@ -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 diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx new file mode 100644 index 0000000..cc24f31 --- /dev/null +++ b/frontend/src/components/Navbar.jsx @@ -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 ( +
+
+ PDF Quiz + {user && ( + + )} +
+
+ ) +} diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..b844b9e --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -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 ( + + {children} + + ) +} + +export const useAuth = () => useContext(AuthContext) diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..f260137 --- /dev/null +++ b/frontend/src/index.css @@ -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; + } +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..5cc5991 --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + , +) diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx new file mode 100644 index 0000000..6fddee6 --- /dev/null +++ b/frontend/src/pages/AdminPage.jsx @@ -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
Loading...
+ + const modelsByTask = TASKS.reduce((acc, t) => { + acc[t] = models.filter(m => m.task === t) + return acc + }, {}) + + return ( +
+
+

Admin Dashboard

+
+ {['models', 'users'].map(t => ( + + ))} +
+
+ + {error &&
{error}
} + {success &&
{success}
} + + {tab === 'models' && ( + <> + {TASKS.map(task => ( +
+

+ {task} Models + + {task === 'extraction' ? '— AI that extracts questions from PDFs' : + task === 'tts' ? '— Text-to-speech for reading questions' : + '— General purpose AI'} + +

+ {modelsByTask[task].length === 0 ? ( +
No models configured
+ ) : ( + modelsByTask[task].map(m => ( +
+
+ {m.name} +
{m.model_id}
+
+
+ {m.is_default && Default} + {!m.is_default && ( + + )} + + +
+
+ )) + )} +
+ ))} + +
+

Add Model

+
+
+
+ + +
+
+ + +
+
+ + setNewModel(m => ({ ...m, name: e.target.value }))} required /> +
+
+ + setNewModel(m => ({ ...m, model_id: e.target.value }))} placeholder="e.g. gpt-4o-mini" required /> +
+
+ + setNewModel(m => ({ ...m, api_key: e.target.value }))} placeholder="Leave blank to use .env key" /> +
+
+ +
+
+ +
+
+ + )} + + {tab === 'users' && ( +
+

Users

+ {users.map(u => ( +
+
+ {u.name} +
{u.email} · joined {new Date(u.created_at).toLocaleDateString()}
+
+
+ + {u.role} + + +
+
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..d55f879 --- /dev/null +++ b/frontend/src/pages/DashboardPage.jsx @@ -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
Loading...
+ + return ( +
+ {stats && ( +
+
+
{stats.total_documents}
+
Documents
+
+
+
{stats.total_quizzes}
+
Quizzes
+
+
+
{stats.total_attempts}
+
Attempts
+
+
+
{stats.average_score}%
+
Avg Score
+
+
+ )} + +
+
+

Your Documents

+ Upload PDF +
+ + {documents.length === 0 ? ( +
+

No documents yet. Upload a PDF to get started!

+
+ ) : ( + documents.map(doc => ( + +
+
+ {doc.original_filename} +
+ {doc.total_pages ? `${doc.total_pages} pages` : 'Processing...'} | {new Date(doc.uploaded_at).toLocaleDateString()} +
+
+ {doc.status} +
+ + )) + )} +
+ + {stats && stats.quiz_stats.length > 0 && ( +
+

Quiz Performance

+ {stats.quiz_stats.map(qs => ( +
+
+ {qs.quiz_title} +
+ {qs.attempts_count} attempts | Best: {qs.best_score}% +
+
+ = 70 ? '#22c55e' : qs.latest_score >= 50 ? '#f59e0b' : '#ef4444' + }}> + {qs.latest_score}% + +
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/DocumentDetailPage.jsx b/frontend/src/pages/DocumentDetailPage.jsx new file mode 100644 index 0000000..820b9f0 --- /dev/null +++ b/frontend/src/pages/DocumentDetailPage.jsx @@ -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
Loading...
+ if (!doc) return null + + return ( +
+
+
+
+

{doc.original_filename}

+

+ {doc.total_pages ? `${doc.total_pages} pages` : ''} · Uploaded {new Date(doc.uploaded_at).toLocaleDateString()} +

+
+
+ {doc.status} + {isModerator && } +
+
+ + {doc.status === 'processing' && ( +
+
+

Processing PDF and extracting text... This may take a while for large files.

+
+ )} + {doc.status === 'error' && ( +
+ Error: {doc.error_message || 'Unknown error'} +
+ )} +
+ + {doc.status === 'ready' && ( + <> + {error &&
{error}
} + + {/* Moderators can create sections */} + {isModerator && ( +
+

Create Section

+

+ Define page ranges — the AI will extract all questions from those pages. +

+
+
+
+ + setSectionForm({ ...sectionForm, name: e.target.value })} + placeholder="e.g., Chapter 1 (pages 1–50)" + required + /> +
+
+
+ + setSectionForm({ ...sectionForm, start_page: parseInt(e.target.value) || 1 })} /> +
+
+ + setSectionForm({ ...sectionForm, end_page: parseInt(e.target.value) || 10 })} /> +
+
+
+ +
+
+ )} + + {/* Sections list */} + {doc.sections && doc.sections.length > 0 && ( +
+

Sections

+ + {/* Quiz settings (moderator only) */} + {isModerator && ( +
+ Quiz Settings +
+
+ + setQuizTitle(e.target.value)} + placeholder="Auto-generated if empty" /> +
+
+ + +
+ {quizMode === 'timed' && ( +
+ + setTimeLimitMinutes(e.target.value)} + placeholder="Leave blank for no limit" /> +
+ )} +
+
+ )} + + {doc.sections.map(section => ( +
+
+ {section.name} +
+ Pages {section.start_page}–{section.end_page} +
+
+
+ {isModerator && ( + + )} +
+
+ ))} +
+ )} + + )} +
+ ) +} diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx new file mode 100644 index 0000000..89479bb --- /dev/null +++ b/frontend/src/pages/LoginPage.jsx @@ -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 ( +
+
+

Sign In

+ {error &&
{error}
} +
+
+ + setEmail(e.target.value)} required /> +
+
+ + setPassword(e.target.value)} required /> +
+ +
+
+ Don't have an account? Sign up +
+
+
+ ) +} diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx new file mode 100644 index 0000000..5725e01 --- /dev/null +++ b/frontend/src/pages/QuizPage.jsx @@ -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 ( + + ) +} + +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 ( +
+
+ {String(mins).padStart(2, '0')}:{String(secs).padStart(2, '0')} +
+
+
+
+
+ ) +} + +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
Loading quiz...
+ 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 ( +
+ {/* Header */} +
+
+

{quiz.title}

+
+ + {isLearning ? 'Learning Mode' : 'Timed Mode'} + + Question {currentIdx + 1} of {totalCount} · {answeredCount} answered +
+
+ {timeLeft !== null && ( + + )} +
+ + {/* Progress bar */} +
+
+
+ + {/* Current question */} + {current && ( +
+
+

+ Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')} +

+ +
+ + + {current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'} + + + {/* Question image */} + {current.image_path && ( +
+ Question illustration e.target.style.display = 'none'} + /> +
+ )} + + {/* Answer options */} + {(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? ( +
+ {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 ( +
setAnswer(current.id, opt)} + > + setAnswer(current.id, opt)} /> + {opt} + {isLearning && isCorrect && ✓ Correct} +
+ ) + })} +
+ ) : ( + 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 && ( +
+ Explanation: {current.explanation} +
+ )} + + {/* Learning mode: show correct answer for fill blank */} + {isLearning && current.question_type === 'fill_blank' && answers[current.id] && ( +
+ Answer: {current.correct_answer} +
+ )} +
+ )} + + {/* Navigation */} +
+ + +
+ {questions.map((q, i) => ( + + ))} +
+ + {isLast ? ( + + ) : ( + + )} +
+ + {answeredCount > 0 && !isLast && ( +
+ +
+ )} +
+ ) +} diff --git a/frontend/src/pages/QuizzesPage.jsx b/frontend/src/pages/QuizzesPage.jsx new file mode 100644 index 0000000..e2b82c7 --- /dev/null +++ b/frontend/src/pages/QuizzesPage.jsx @@ -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
Loading...
+ + return ( +
+
+

Your Quizzes

+ {quizzes.length === 0 ? ( +
+

No quizzes yet. Upload a PDF and generate quizzes from sections.

+
+ ) : ( + quizzes.map(quiz => ( +
+
+ {quiz.title} +
+ {quiz.questions_count} questions | {new Date(quiz.created_at).toLocaleDateString()} +
+
+
+ Take Quiz +
+
+ )) + )} +
+
+ ) +} diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx new file mode 100644 index 0000000..7aa990b --- /dev/null +++ b/frontend/src/pages/RegisterPage.jsx @@ -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 ( +
+
+

Create Account

+ {error &&
{error}
} +
+
+ + setName(e.target.value)} required /> +
+
+ + setEmail(e.target.value)} required /> +
+
+ + setPassword(e.target.value)} required minLength={6} /> +
+ +
+
+ Already have an account? Sign in +
+
+
+ ) +} diff --git a/frontend/src/pages/ResultsPage.jsx b/frontend/src/pages/ResultsPage.jsx new file mode 100644 index 0000000..488e2f7 --- /dev/null +++ b/frontend/src/pages/ResultsPage.jsx @@ -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
Loading results...
+ if (!result) return null + + const scoreClass = result.percentage >= 70 ? 'good' : result.percentage >= 50 ? 'ok' : 'poor' + + return ( +
+
+
+
{result.percentage}%
+
+ {result.score} out of {result.total_questions} correct +
+
+ {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!'} +
+
+ Retake Quiz + Dashboard +
+
+
+ +

Question Review

+ {result.answers?.map((ans, idx) => ( +
+

Q{idx + 1}. {ans.question_text}

+
+
+ + {ans.is_correct ? 'Correct' : 'Incorrect'} + +
+
+
Your answer: {ans.user_answer || '(no answer)'}
+ {!ans.is_correct && ( +
Correct answer: {ans.correct_answer}
+ )} +
+
+ {ans.explanation && ( +
+ Explanation: {ans.explanation} +
+ )} +
+ ))} +
+ ) +} diff --git a/frontend/src/pages/UploadPage.jsx b/frontend/src/pages/UploadPage.jsx new file mode 100644 index 0000000..c7cb63f --- /dev/null +++ b/frontend/src/pages/UploadPage.jsx @@ -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 ( +
+
+

Upload PDF Document

+

+ Upload a PDF file (up to 500MB) to generate interactive quizzes from its content. +

+ + {error &&
{error}
} + +
fileRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault() + setDragging(false) + handleFile(e.dataTransfer.files[0]) + }} + > + {file ? ( +
+
{file.name}
+
+ {(file.size / 1024 / 1024).toFixed(1)} MB +
+
+ ) : ( +
+
PDF
+
Click or drag a PDF file here
+
+ Supports files up to 500MB +
+
+ )} +
+ + handleFile(e.target.files[0])} + /> + + {uploading && ( +
+
+
+ )} + +
+ + +
+
+
+ ) +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..6318ceb --- /dev/null +++ b/frontend/vite.config.js @@ -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' + } + } +})