Initial commit: PDF Quiz Generator app
- FastAPI backend with JWT auth, roles (admin/moderator/user) - PDF upload (up to 500MB) with streaming, PyMuPDF text extraction - ChromaDB vectorization per page with metadata - LiteLLM AI question extraction from PDF (not generation) - Image extraction from PDF pages, graceful fallback - Quiz modes: timed (countdown timer) + learning (answers shown inline) - Page-by-page question navigation with dot navigator - TTS endpoint using LiteLLM (Google Vertex / OpenAI voices) - Admin dashboard: AI model management per task, user role management - Moderator role: upload PDFs, create sections, generate quizzes - Spaced repetition reminders via SMTP email (SM-2 intervals) - APScheduler daily reminder jobs - Celery + Redis for background PDF processing - React frontend with all pages - Docker Compose deployment (nginx + backend + celery + redis) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
b876f13fac
72 changed files with 4664 additions and 0 deletions
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
venv/
|
||||
.env
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Data
|
||||
uploads/
|
||||
chroma_data/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
.npm/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
*.swp
|
||||
8
backend/.dockerignore
Normal file
8
backend/.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
uploads/
|
||||
chroma_data/
|
||||
.env
|
||||
alembic/versions/__pycache__/
|
||||
28
backend/.env.example
Normal file
28
backend/.env.example
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Database
|
||||
DATABASE_URL=sqlite:////app/data/quiz.db
|
||||
SECRET_KEY=change-me-to-a-random-secret-key-in-production
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
|
||||
# Redis (use service name in Docker)
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# AI - LiteLLM (supports OpenAI, Anthropic, etc.)
|
||||
LITELLM_MODEL=gpt-4o-mini
|
||||
LITELLM_API_KEY=your-api-key-here
|
||||
|
||||
# Vector store
|
||||
CHROMA_PERSIST_DIR=/app/chroma_data
|
||||
|
||||
# SMTP Email for reminders
|
||||
MAIL_USERNAME=your-email@example.com
|
||||
MAIL_PASSWORD=your-app-password
|
||||
MAIL_FROM=your-email@example.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SERVER=smtp.gmail.com
|
||||
MAIL_STARTTLS=true
|
||||
MAIL_SSL_TLS=false
|
||||
|
||||
# File uploads
|
||||
UPLOAD_DIR=/app/uploads
|
||||
MAX_UPLOAD_SIZE=524288000
|
||||
21
backend/Dockerfile
Normal file
21
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system deps
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
pip install --no-cache-dir "numpy<2"
|
||||
|
||||
COPY . .
|
||||
|
||||
# Create dirs
|
||||
RUN mkdir -p uploads chroma_data
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
116
backend/alembic.ini
Normal file
116
backend/alembic.ini
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = sqlite:///./quiz.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
|
|
@ -0,0 +1 @@
|
|||
Generic single-database configuration.
|
||||
77
backend/alembic/env.py
Normal file
77
backend/alembic/env.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
from app.models import User, PDFDocument, Section, Quiz, Question, QuizAttempt, AttemptAnswer, ReminderSchedule # noqa
|
||||
from app.database import Base
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
142
backend/alembic/versions/97ef9b957a02_initial_tables.py
Normal file
142
backend/alembic/versions/97ef9b957a02_initial_tables.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""initial tables
|
||||
|
||||
Revision ID: 97ef9b957a02
|
||||
Revises:
|
||||
Create Date: 2026-03-30 18:20:24.251948
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '97ef9b957a02'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=False),
|
||||
sa.Column('hashed_password', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||
op.create_table('pdf_documents',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('filename', sa.String(), nullable=False),
|
||||
sa.Column('original_filename', sa.String(), nullable=False),
|
||||
sa.Column('total_pages', sa.Integer(), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=True),
|
||||
sa.Column('error_message', sa.String(), nullable=True),
|
||||
sa.Column('uploaded_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_pdf_documents_id'), 'pdf_documents', ['id'], unique=False)
|
||||
op.create_table('sections',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('document_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('start_page', sa.Integer(), nullable=False),
|
||||
sa.Column('end_page', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['document_id'], ['pdf_documents.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_sections_id'), 'sections', ['id'], unique=False)
|
||||
op.create_table('quizzes',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('section_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(), nullable=False),
|
||||
sa.Column('questions_count', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['section_id'], ['sections.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_quizzes_id'), 'quizzes', ['id'], unique=False)
|
||||
op.create_table('questions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('quiz_id', sa.Integer(), nullable=False),
|
||||
sa.Column('question_text', sa.Text(), nullable=False),
|
||||
sa.Column('question_type', sa.String(), nullable=False),
|
||||
sa.Column('options', sa.JSON(), nullable=True),
|
||||
sa.Column('correct_answer', sa.String(), nullable=False),
|
||||
sa.Column('explanation', sa.Text(), nullable=True),
|
||||
sa.Column('page_reference', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['quiz_id'], ['quizzes.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_questions_id'), 'questions', ['id'], unique=False)
|
||||
op.create_table('quiz_attempts',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('quiz_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('score', sa.Integer(), nullable=True),
|
||||
sa.Column('total_questions', sa.Integer(), nullable=True),
|
||||
sa.Column('started_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['quiz_id'], ['quizzes.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_quiz_attempts_id'), 'quiz_attempts', ['id'], unique=False)
|
||||
op.create_table('reminder_schedules',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('quiz_id', sa.Integer(), nullable=False),
|
||||
sa.Column('next_reminder_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('interval_days', sa.Integer(), nullable=True),
|
||||
sa.Column('performance_score', sa.Float(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['quiz_id'], ['quizzes.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_reminder_schedules_id'), 'reminder_schedules', ['id'], unique=False)
|
||||
op.create_table('attempt_answers',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('attempt_id', sa.Integer(), nullable=False),
|
||||
sa.Column('question_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_answer', sa.String(), nullable=False),
|
||||
sa.Column('is_correct', sa.Boolean(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['attempt_id'], ['quiz_attempts.id'], ),
|
||||
sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_attempt_answers_id'), 'attempt_answers', ['id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_attempt_answers_id'), table_name='attempt_answers')
|
||||
op.drop_table('attempt_answers')
|
||||
op.drop_index(op.f('ix_reminder_schedules_id'), table_name='reminder_schedules')
|
||||
op.drop_table('reminder_schedules')
|
||||
op.drop_index(op.f('ix_quiz_attempts_id'), table_name='quiz_attempts')
|
||||
op.drop_table('quiz_attempts')
|
||||
op.drop_index(op.f('ix_questions_id'), table_name='questions')
|
||||
op.drop_table('questions')
|
||||
op.drop_index(op.f('ix_quizzes_id'), table_name='quizzes')
|
||||
op.drop_table('quizzes')
|
||||
op.drop_index(op.f('ix_sections_id'), table_name='sections')
|
||||
op.drop_table('sections')
|
||||
op.drop_index(op.f('ix_pdf_documents_id'), table_name='pdf_documents')
|
||||
op.drop_table('pdf_documents')
|
||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
"""add roles images modes models
|
||||
|
||||
Revision ID: c3aafcd58735
|
||||
Revises: 97ef9b957a02
|
||||
Create Date: 2026-03-30 19:43:35.616037
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c3aafcd58735'
|
||||
down_revision: Union[str, None] = '97ef9b957a02'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('ai_model_configs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('model_id', sa.String(), nullable=False),
|
||||
sa.Column('task', sa.String(), nullable=False),
|
||||
sa.Column('api_key', sa.String(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('model_id')
|
||||
)
|
||||
op.create_index(op.f('ix_ai_model_configs_id'), 'ai_model_configs', ['id'], unique=False)
|
||||
op.add_column('questions', sa.Column('image_path', sa.String(), nullable=True))
|
||||
op.add_column('quizzes', sa.Column('time_limit_minutes', sa.Integer(), nullable=True))
|
||||
op.add_column('quizzes', sa.Column('mode', sa.String(), nullable=True))
|
||||
op.add_column('users', sa.Column('role', sa.String(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'role')
|
||||
op.drop_column('quizzes', 'mode')
|
||||
op.drop_column('quizzes', 'time_limit_minutes')
|
||||
op.drop_column('questions', 'image_path')
|
||||
op.drop_index(op.f('ix_ai_model_configs_id'), table_name='ai_model_configs')
|
||||
op.drop_table('ai_model_configs')
|
||||
# ### end Alembic commands ###
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
31
backend/app/config.py
Normal file
31
backend/app/config.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
DATABASE_URL: str = "sqlite:///./quiz.db"
|
||||
SECRET_KEY: str = "change-me-to-a-random-secret-key-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
|
||||
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
LITELLM_MODEL: str = "gpt-4o-mini"
|
||||
LITELLM_API_KEY: str = ""
|
||||
|
||||
CHROMA_PERSIST_DIR: str = "./chroma_data"
|
||||
|
||||
MAIL_USERNAME: str = ""
|
||||
MAIL_PASSWORD: str = ""
|
||||
MAIL_FROM: str = ""
|
||||
MAIL_PORT: int = 587
|
||||
MAIL_SERVER: str = "smtp.gmail.com"
|
||||
MAIL_STARTTLS: bool = True
|
||||
MAIL_SSL_TLS: bool = False
|
||||
|
||||
UPLOAD_DIR: str = "./uploads"
|
||||
MAX_UPLOAD_SIZE: int = 524288000 # 500MB
|
||||
|
||||
|
||||
settings = Settings()
|
||||
28
backend/app/database.py
Normal file
28
backend/app/database.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.close()
|
||||
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
126
backend/app/main.py
Normal file
126
backend/app/main.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config import settings
|
||||
from app.database import engine, Base, SessionLocal
|
||||
from app.routers import auth, documents, quizzes, attempts, admin, tts
|
||||
from app.utils.auth import get_password_hash
|
||||
from app.utils.scheduler import start_scheduler, stop_scheduler
|
||||
|
||||
|
||||
def seed_admin():
|
||||
"""Create default admin user if none exists."""
|
||||
from app.models.user import User
|
||||
db = SessionLocal()
|
||||
try:
|
||||
admin_exists = db.query(User).filter(User.role == "admin").first()
|
||||
if not admin_exists:
|
||||
admin_user = User(
|
||||
email="admin@quizapp.com",
|
||||
hashed_password=get_password_hash("admin123"),
|
||||
name="Admin",
|
||||
role="admin",
|
||||
)
|
||||
db.add(admin_user)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def seed_default_models():
|
||||
"""Seed default AI model configs if none exist."""
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if db.query(AIModelConfig).count() == 0:
|
||||
defaults = [
|
||||
AIModelConfig(
|
||||
name="GPT-4o Mini (Extraction)",
|
||||
model_id="gpt-4o-mini",
|
||||
task="extraction",
|
||||
is_active=True,
|
||||
is_default=True,
|
||||
),
|
||||
AIModelConfig(
|
||||
name="GPT-4o (Extraction)",
|
||||
model_id="gpt-4o",
|
||||
task="extraction",
|
||||
is_active=True,
|
||||
is_default=False,
|
||||
),
|
||||
AIModelConfig(
|
||||
name="Claude Sonnet 4.6 (Extraction)",
|
||||
model_id="anthropic/claude-sonnet-4-6-20250514",
|
||||
task="extraction",
|
||||
is_active=True,
|
||||
is_default=False,
|
||||
),
|
||||
AIModelConfig(
|
||||
name="Google Vertex TTS",
|
||||
model_id="vertex_ai/google/cloud-tts",
|
||||
task="tts",
|
||||
is_active=True,
|
||||
is_default=True,
|
||||
),
|
||||
AIModelConfig(
|
||||
name="OpenAI TTS",
|
||||
model_id="openai/tts-1",
|
||||
task="tts",
|
||||
is_active=True,
|
||||
is_default=False,
|
||||
),
|
||||
]
|
||||
db.add_all(defaults)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
Base.metadata.create_all(bind=engine)
|
||||
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
||||
os.makedirs(os.path.join(settings.UPLOAD_DIR, "images"), exist_ok=True)
|
||||
os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
|
||||
seed_admin()
|
||||
seed_default_models()
|
||||
start_scheduler()
|
||||
yield
|
||||
# Shutdown
|
||||
stop_scheduler()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="PDF Quiz Generator",
|
||||
description="Convert PDF files into interactive quizzes with AI",
|
||||
version="2.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://localhost:3000", "http://localhost"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Serve uploaded images as static files
|
||||
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(documents.router, prefix="/api/documents", tags=["documents"])
|
||||
app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"])
|
||||
app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"])
|
||||
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
|
||||
app.include_router(tts.router, prefix="/api/tts", tags=["tts"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health_check():
|
||||
return {"status": "ok"}
|
||||
20
backend/app/models/__init__.py
Normal file
20
backend/app/models/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from app.models.user import User
|
||||
from app.models.pdf_document import PDFDocument
|
||||
from app.models.section import Section
|
||||
from app.models.quiz import Quiz
|
||||
from app.models.question import Question
|
||||
from app.models.attempt import QuizAttempt, AttemptAnswer
|
||||
from app.models.reminder import ReminderSchedule
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"PDFDocument",
|
||||
"Section",
|
||||
"Quiz",
|
||||
"Question",
|
||||
"QuizAttempt",
|
||||
"AttemptAnswer",
|
||||
"ReminderSchedule",
|
||||
"AIModelConfig",
|
||||
]
|
||||
18
backend/app/models/ai_model_config.py
Normal file
18
backend/app/models/ai_model_config.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AIModelConfig(Base):
|
||||
__tablename__ = "ai_model_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False) # display name
|
||||
model_id = Column(String, nullable=False, unique=True) # litellm model id e.g. gpt-4o-mini
|
||||
task = Column(String, nullable=False) # extraction, tts, general
|
||||
api_key = Column(String, nullable=True) # override api key, if null uses default
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_default = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
35
backend/app/models/attempt.py
Normal file
35
backend/app/models/attempt.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, Boolean, String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class QuizAttempt(Base):
|
||||
__tablename__ = "quiz_attempts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
score = Column(Integer, default=0)
|
||||
total_questions = Column(Integer, default=0)
|
||||
started_at = Column(DateTime, default=datetime.utcnow)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
quiz = relationship("Quiz", back_populates="attempts")
|
||||
user = relationship("User", back_populates="attempts")
|
||||
answers = relationship("AttemptAnswer", back_populates="attempt", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class AttemptAnswer(Base):
|
||||
__tablename__ = "attempt_answers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
attempt_id = Column(Integer, ForeignKey("quiz_attempts.id"), nullable=False)
|
||||
question_id = Column(Integer, ForeignKey("questions.id"), nullable=False)
|
||||
user_answer = Column(String, nullable=False)
|
||||
is_correct = Column(Boolean, default=False)
|
||||
|
||||
attempt = relationship("QuizAttempt", back_populates="answers")
|
||||
question = relationship("Question")
|
||||
22
backend/app/models/pdf_document.py
Normal file
22
backend/app/models/pdf_document.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class PDFDocument(Base):
|
||||
__tablename__ = "pdf_documents"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
filename = Column(String, nullable=False)
|
||||
original_filename = Column(String, nullable=False)
|
||||
total_pages = Column(Integer, nullable=True)
|
||||
status = Column(String, default="processing") # processing, ready, error
|
||||
error_message = Column(String, nullable=True)
|
||||
uploaded_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="documents")
|
||||
sections = relationship("Section", back_populates="document", cascade="all, delete-orphan")
|
||||
20
backend/app/models/question.py
Normal file
20
backend/app/models/question.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from sqlalchemy import Column, Integer, String, Text, JSON, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Question(Base):
|
||||
__tablename__ = "questions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False)
|
||||
question_text = Column(Text, nullable=False)
|
||||
question_type = Column(String, nullable=False) # mcq, true_false, fill_blank
|
||||
options = Column(JSON, nullable=True) # list of strings for mcq
|
||||
correct_answer = Column(String, nullable=False)
|
||||
explanation = Column(Text, nullable=True)
|
||||
page_reference = Column(Integer, nullable=True)
|
||||
image_path = Column(String, nullable=True) # path to extracted image, if any
|
||||
|
||||
quiz = relationship("Quiz", back_populates="questions")
|
||||
24
backend/app/models/quiz.py
Normal file
24
backend/app/models/quiz.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Quiz(Base):
|
||||
__tablename__ = "quizzes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
section_id = Column(Integer, ForeignKey("sections.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
title = Column(String, nullable=False)
|
||||
questions_count = Column(Integer, default=0)
|
||||
time_limit_minutes = Column(Integer, nullable=True) # null = no limit
|
||||
mode = Column(String, default="timed") # timed, learning
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
section = relationship("Section", back_populates="quizzes")
|
||||
user = relationship("User", back_populates="quizzes")
|
||||
questions = relationship("Question", back_populates="quiz", cascade="all, delete-orphan")
|
||||
attempts = relationship("QuizAttempt", back_populates="quiz", cascade="all, delete-orphan")
|
||||
23
backend/app/models/reminder.py
Normal file
23
backend/app/models/reminder.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, Float, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ReminderSchedule(Base):
|
||||
__tablename__ = "reminder_schedules"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False)
|
||||
next_reminder_at = Column(DateTime, nullable=False)
|
||||
interval_days = Column(Integer, default=1)
|
||||
performance_score = Column(Float, default=0.0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="reminders")
|
||||
quiz = relationship("Quiz")
|
||||
17
backend/app/models/section.py
Normal file
17
backend/app/models/section.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from sqlalchemy import Column, Integer, String, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Section(Base):
|
||||
__tablename__ = "sections"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
document_id = Column(Integer, ForeignKey("pdf_documents.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
start_page = Column(Integer, nullable=False)
|
||||
end_page = Column(Integer, nullable=False)
|
||||
|
||||
document = relationship("PDFDocument", back_populates="sections")
|
||||
quizzes = relationship("Quiz", back_populates="section", cascade="all, delete-orphan")
|
||||
30
backend/app/models/user.py
Normal file
30
backend/app/models/user.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
role = Column(String, default="user") # admin, moderator, user
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
documents = relationship("PDFDocument", back_populates="user")
|
||||
quizzes = relationship("Quiz", back_populates="user")
|
||||
attempts = relationship("QuizAttempt", back_populates="user")
|
||||
reminders = relationship("ReminderSchedule", back_populates="user")
|
||||
|
||||
@property
|
||||
def is_admin(self):
|
||||
return self.role == "admin"
|
||||
|
||||
@property
|
||||
def is_moderator(self):
|
||||
return self.role in ("admin", "moderator")
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
119
backend/app/routers/admin.py
Normal file
119
backend/app/routers/admin.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
from app.schemas.auth import UserResponse, UserUpdateRole
|
||||
from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate
|
||||
from app.utils.auth import require_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --- User Management ---
|
||||
|
||||
@router.get("/users", response_model=list[UserResponse])
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
return db.query(User).order_by(User.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/role", response_model=UserResponse)
|
||||
def update_user_role(
|
||||
user_id: int,
|
||||
role_data: UserUpdateRole,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
if role_data.role not in ("admin", "moderator", "user"):
|
||||
raise HTTPException(status_code=400, detail="Role must be admin, moderator, or user")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot change your own role")
|
||||
|
||||
user.role = role_data.role
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
# --- AI Model Configuration ---
|
||||
|
||||
@router.get("/models", response_model=list[AIModelConfigResponse])
|
||||
def list_models(
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
return db.query(AIModelConfig).order_by(AIModelConfig.task, AIModelConfig.name).all()
|
||||
|
||||
|
||||
@router.post("/models", response_model=AIModelConfigResponse)
|
||||
def create_model(
|
||||
data: AIModelConfigCreate,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
if data.task not in ("extraction", "tts", "general"):
|
||||
raise HTTPException(status_code=400, detail="Task must be extraction, tts, or general")
|
||||
|
||||
# If setting as default, unset other defaults for same task
|
||||
if data.is_default:
|
||||
db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == data.task,
|
||||
AIModelConfig.is_default == True,
|
||||
).update({"is_default": False})
|
||||
|
||||
model = AIModelConfig(**data.model_dump())
|
||||
db.add(model)
|
||||
db.commit()
|
||||
db.refresh(model)
|
||||
return model
|
||||
|
||||
|
||||
@router.put("/models/{model_id}", response_model=AIModelConfigResponse)
|
||||
def update_model(
|
||||
model_id: int,
|
||||
data: AIModelConfigUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="Model config not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# If setting as default, unset other defaults for same task
|
||||
task = update_data.get("task", model.task)
|
||||
if update_data.get("is_default"):
|
||||
db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == task,
|
||||
AIModelConfig.is_default == True,
|
||||
AIModelConfig.id != model_id,
|
||||
).update({"is_default": False})
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(model, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(model)
|
||||
return model
|
||||
|
||||
|
||||
@router.delete("/models/{model_id}", status_code=204)
|
||||
def delete_model(
|
||||
model_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="Model config not found")
|
||||
db.delete(model)
|
||||
db.commit()
|
||||
236
backend/app/routers/attempts.py
Normal file
236
backend/app/routers/attempts.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.quiz import Quiz
|
||||
from app.models.question import Question
|
||||
from app.models.attempt import QuizAttempt, AttemptAnswer
|
||||
from app.models.pdf_document import PDFDocument
|
||||
from app.models.user import User
|
||||
from app.schemas.attempt import (
|
||||
AttemptSubmit,
|
||||
AttemptResponse,
|
||||
AttemptDetail,
|
||||
AnswerDetail,
|
||||
DashboardStats,
|
||||
QuizStats,
|
||||
)
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/start", response_model=AttemptResponse)
|
||||
def start_attempt(
|
||||
quiz_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.user_id == current_user.id).first()
|
||||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
|
||||
attempt = QuizAttempt(
|
||||
quiz_id=quiz_id,
|
||||
user_id=current_user.id,
|
||||
total_questions=quiz.questions_count,
|
||||
)
|
||||
db.add(attempt)
|
||||
db.commit()
|
||||
db.refresh(attempt)
|
||||
return AttemptResponse(
|
||||
id=attempt.id,
|
||||
quiz_id=attempt.quiz_id,
|
||||
score=0,
|
||||
total_questions=attempt.total_questions,
|
||||
percentage=0.0,
|
||||
started_at=attempt.started_at,
|
||||
completed_at=None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{attempt_id}/submit", response_model=AttemptDetail)
|
||||
def submit_attempt(
|
||||
attempt_id: int,
|
||||
submission: AttemptSubmit,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
attempt = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.id == attempt_id,
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
).first()
|
||||
if not attempt:
|
||||
raise HTTPException(status_code=404, detail="Attempt not found")
|
||||
if attempt.completed_at:
|
||||
raise HTTPException(status_code=400, detail="Attempt already submitted")
|
||||
|
||||
# Get all questions for this quiz
|
||||
questions = {
|
||||
q.id: q for q in db.query(Question).filter(Question.quiz_id == attempt.quiz_id).all()
|
||||
}
|
||||
|
||||
score = 0
|
||||
answer_details = []
|
||||
|
||||
for ans in submission.answers:
|
||||
question = questions.get(ans.question_id)
|
||||
if not question:
|
||||
continue
|
||||
|
||||
is_correct = ans.user_answer.strip().lower() == question.correct_answer.strip().lower()
|
||||
if is_correct:
|
||||
score += 1
|
||||
|
||||
attempt_answer = AttemptAnswer(
|
||||
attempt_id=attempt_id,
|
||||
question_id=ans.question_id,
|
||||
user_answer=ans.user_answer,
|
||||
is_correct=is_correct,
|
||||
)
|
||||
db.add(attempt_answer)
|
||||
|
||||
answer_details.append(AnswerDetail(
|
||||
question_id=question.id,
|
||||
question_text=question.question_text,
|
||||
question_type=question.question_type,
|
||||
user_answer=ans.user_answer,
|
||||
correct_answer=question.correct_answer,
|
||||
is_correct=is_correct,
|
||||
explanation=question.explanation,
|
||||
))
|
||||
|
||||
attempt.score = score
|
||||
attempt.completed_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
|
||||
|
||||
# Update reminder schedule
|
||||
try:
|
||||
from app.services.reminder_service import update_reminder_schedule
|
||||
update_reminder_schedule(db, current_user.id, attempt.quiz_id, percentage)
|
||||
except Exception:
|
||||
pass # Don't fail submission if reminder update fails
|
||||
|
||||
return AttemptDetail(
|
||||
id=attempt.id,
|
||||
quiz_id=attempt.quiz_id,
|
||||
score=score,
|
||||
total_questions=attempt.total_questions,
|
||||
percentage=round(percentage, 1),
|
||||
started_at=attempt.started_at,
|
||||
completed_at=attempt.completed_at,
|
||||
answers=answer_details,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[AttemptResponse])
|
||||
def list_attempts(
|
||||
quiz_id: int | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
query = db.query(QuizAttempt).filter(QuizAttempt.user_id == current_user.id)
|
||||
if quiz_id:
|
||||
query = query.filter(QuizAttempt.quiz_id == quiz_id)
|
||||
attempts = query.order_by(QuizAttempt.started_at.desc()).all()
|
||||
|
||||
return [
|
||||
AttemptResponse(
|
||||
id=a.id,
|
||||
quiz_id=a.quiz_id,
|
||||
score=a.score,
|
||||
total_questions=a.total_questions,
|
||||
percentage=round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1),
|
||||
started_at=a.started_at,
|
||||
completed_at=a.completed_at,
|
||||
)
|
||||
for a in attempts
|
||||
]
|
||||
|
||||
|
||||
@router.get("/stats/dashboard", response_model=DashboardStats)
|
||||
def get_dashboard_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
total_docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).count()
|
||||
total_quizzes = db.query(Quiz).filter(Quiz.user_id == current_user.id).count()
|
||||
|
||||
completed_attempts = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.isnot(None),
|
||||
).all()
|
||||
|
||||
total_attempts = len(completed_attempts)
|
||||
avg_score = 0.0
|
||||
if completed_attempts:
|
||||
scores = [(a.score / a.total_questions * 100) if a.total_questions > 0 else 0 for a in completed_attempts]
|
||||
avg_score = round(sum(scores) / len(scores), 1)
|
||||
|
||||
# Per-quiz stats
|
||||
quiz_stats = []
|
||||
quizzes = db.query(Quiz).filter(Quiz.user_id == current_user.id).all()
|
||||
for quiz in quizzes:
|
||||
quiz_attempts = [a for a in completed_attempts if a.quiz_id == quiz.id]
|
||||
if quiz_attempts:
|
||||
pcts = [(a.score / a.total_questions * 100) if a.total_questions > 0 else 0 for a in quiz_attempts]
|
||||
quiz_stats.append(QuizStats(
|
||||
quiz_id=quiz.id,
|
||||
quiz_title=quiz.title,
|
||||
attempts_count=len(quiz_attempts),
|
||||
best_score=round(max(pcts), 1),
|
||||
latest_score=round(pcts[-1], 1),
|
||||
average_score=round(sum(pcts) / len(pcts), 1),
|
||||
))
|
||||
|
||||
return DashboardStats(
|
||||
total_documents=total_docs,
|
||||
total_quizzes=total_quizzes,
|
||||
total_attempts=total_attempts,
|
||||
average_score=avg_score,
|
||||
quiz_stats=quiz_stats,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{attempt_id}", response_model=AttemptDetail)
|
||||
def get_attempt(
|
||||
attempt_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
attempt = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.id == attempt_id,
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
).first()
|
||||
if not attempt:
|
||||
raise HTTPException(status_code=404, detail="Attempt not found")
|
||||
|
||||
answer_details = []
|
||||
for ans in attempt.answers:
|
||||
question = ans.question
|
||||
answer_details.append(AnswerDetail(
|
||||
question_id=question.id,
|
||||
question_text=question.question_text,
|
||||
question_type=question.question_type,
|
||||
user_answer=ans.user_answer,
|
||||
correct_answer=question.correct_answer,
|
||||
is_correct=ans.is_correct,
|
||||
explanation=question.explanation,
|
||||
))
|
||||
|
||||
percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
|
||||
return AttemptDetail(
|
||||
id=attempt.id,
|
||||
quiz_id=attempt.quiz_id,
|
||||
score=attempt.score,
|
||||
total_questions=attempt.total_questions,
|
||||
percentage=round(percentage, 1),
|
||||
started_at=attempt.started_at,
|
||||
completed_at=attempt.completed_at,
|
||||
answers=answer_details,
|
||||
)
|
||||
49
backend/app/routers/auth.py
Normal file
49
backend/app/routers/auth.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import UserCreate, UserResponse, Token, LoginRequest
|
||||
from app.utils.auth import get_password_hash, verify_password, create_access_token, get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/register", response_model=Token)
|
||||
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
existing = db.query(User).filter(User.email == user_data.email).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered",
|
||||
)
|
||||
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
hashed_password=get_password_hash(user_data.password),
|
||||
name=user_data.name,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
access_token = create_access_token(data={"sub": user.email})
|
||||
return Token(access_token=access_token)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(login_data: LoginRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.email == login_data.email).first()
|
||||
if not user or not verify_password(login_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": user.email})
|
||||
return Token(access_token=access_token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
180
backend/app/routers/documents.py
Normal file
180
backend/app/routers/documents.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import os
|
||||
import uuid
|
||||
import shutil
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.pdf_document import PDFDocument
|
||||
from app.models.section import Section
|
||||
from app.models.user import User
|
||||
from app.schemas.document import DocumentResponse, DocumentStatusResponse, SectionCreate, SectionResponse
|
||||
from app.utils.auth import get_current_user, require_moderator
|
||||
from app.services import vector_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/upload", response_model=DocumentResponse)
|
||||
def upload_document(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
if not file.filename or not file.filename.lower().endswith(".pdf"):
|
||||
raise HTTPException(status_code=400, detail="Only PDF files are accepted")
|
||||
|
||||
# Save file to disk streaming (handles large files)
|
||||
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
||||
safe_name = f"{uuid.uuid4()}_{file.filename}"
|
||||
file_path = os.path.join(settings.UPLOAD_DIR, safe_name)
|
||||
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer, length=1024 * 1024)
|
||||
|
||||
# Check file size
|
||||
file_size = os.path.getsize(file_path)
|
||||
if file_size > settings.MAX_UPLOAD_SIZE:
|
||||
os.remove(file_path)
|
||||
raise HTTPException(status_code=400, detail=f"File too large. Max size: {settings.MAX_UPLOAD_SIZE} bytes")
|
||||
|
||||
# Create DB record
|
||||
doc = PDFDocument(
|
||||
user_id=current_user.id,
|
||||
filename=safe_name,
|
||||
original_filename=file.filename,
|
||||
status="processing",
|
||||
)
|
||||
db.add(doc)
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
|
||||
# Dispatch background processing
|
||||
try:
|
||||
from app.tasks.pdf_tasks import process_pdf
|
||||
process_pdf.delay(doc.id, file_path)
|
||||
except Exception:
|
||||
# If Celery/Redis not available, process synchronously
|
||||
from app.services import pdf_service
|
||||
try:
|
||||
total_pages = pdf_service.get_page_count(file_path)
|
||||
doc.total_pages = total_pages
|
||||
pages = pdf_service.extract_text_by_page(file_path)
|
||||
if pages:
|
||||
vector_service.store_pages(doc.id, pages)
|
||||
doc.status = "ready"
|
||||
else:
|
||||
doc.status = "error"
|
||||
doc.error_message = "No text could be extracted"
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
doc.status = "error"
|
||||
doc.error_message = str(e)[:500]
|
||||
db.commit()
|
||||
|
||||
db.refresh(doc)
|
||||
return doc
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DocumentResponse])
|
||||
def list_documents(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if current_user.is_moderator:
|
||||
docs = db.query(PDFDocument).order_by(PDFDocument.uploaded_at.desc()).all()
|
||||
else:
|
||||
docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).order_by(PDFDocument.uploaded_at.desc()).all()
|
||||
return docs
|
||||
|
||||
|
||||
@router.get("/{document_id}", response_model=DocumentResponse)
|
||||
def get_document(
|
||||
document_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
doc = db.query(PDFDocument).filter(
|
||||
PDFDocument.id == document_id,
|
||||
PDFDocument.user_id == current_user.id,
|
||||
).first()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return doc
|
||||
|
||||
|
||||
@router.get("/{document_id}/status", response_model=DocumentStatusResponse)
|
||||
def get_document_status(
|
||||
document_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
doc = db.query(PDFDocument).filter(
|
||||
PDFDocument.id == document_id,
|
||||
PDFDocument.user_id == current_user.id,
|
||||
).first()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return DocumentStatusResponse(
|
||||
id=doc.id,
|
||||
status=doc.status,
|
||||
total_pages=doc.total_pages,
|
||||
error_message=doc.error_message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{document_id}/sections", response_model=SectionResponse)
|
||||
def create_section(
|
||||
document_id: int,
|
||||
section_data: SectionCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
doc = db.query(PDFDocument).filter(
|
||||
PDFDocument.id == document_id,
|
||||
PDFDocument.user_id == current_user.id,
|
||||
).first()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
if doc.status != "ready":
|
||||
raise HTTPException(status_code=400, detail="Document is not ready yet")
|
||||
if doc.total_pages and section_data.end_page > doc.total_pages:
|
||||
raise HTTPException(status_code=400, detail=f"end_page exceeds document length ({doc.total_pages} pages)")
|
||||
|
||||
section = Section(
|
||||
document_id=document_id,
|
||||
name=section_data.name,
|
||||
start_page=section_data.start_page,
|
||||
end_page=section_data.end_page,
|
||||
)
|
||||
db.add(section)
|
||||
db.commit()
|
||||
db.refresh(section)
|
||||
return section
|
||||
|
||||
|
||||
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_document(
|
||||
document_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
doc = db.query(PDFDocument).filter(
|
||||
PDFDocument.id == document_id,
|
||||
PDFDocument.user_id == current_user.id,
|
||||
).first()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
# Delete file
|
||||
file_path = os.path.join(settings.UPLOAD_DIR, doc.filename)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
# Delete ChromaDB collection
|
||||
vector_service.delete_collection(document_id)
|
||||
|
||||
db.delete(doc)
|
||||
db.commit()
|
||||
109
backend/app/routers/quizzes.py
Normal file
109
backend/app/routers/quizzes.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.quiz import Quiz
|
||||
from app.models.section import Section
|
||||
from app.models.attempt import QuizAttempt
|
||||
from app.models.user import User
|
||||
from app.schemas.quiz import QuizCreate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview
|
||||
from app.services import quiz_service
|
||||
from app.utils.auth import get_current_user, require_moderator
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=QuizResponse)
|
||||
def create_quiz(
|
||||
quiz_data: QuizCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
"""Create quiz by extracting questions from PDF section. Moderator/Admin only."""
|
||||
section = db.query(Section).filter(Section.id == quiz_data.section_id).first()
|
||||
if not section:
|
||||
raise HTTPException(status_code=404, detail="Section not found")
|
||||
if not current_user.is_admin and section.document.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your document")
|
||||
|
||||
if quiz_data.mode not in ("timed", "learning"):
|
||||
raise HTTPException(status_code=400, detail="Mode must be 'timed' or 'learning'")
|
||||
|
||||
try:
|
||||
quiz = quiz_service.create_quiz_from_section(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
section_id=quiz_data.section_id,
|
||||
title=quiz_data.title,
|
||||
mode=quiz_data.mode,
|
||||
time_limit_minutes=quiz_data.time_limit_minutes,
|
||||
)
|
||||
return quiz
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/", response_model=list[QuizResponse])
|
||||
def list_quizzes(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all available quizzes. Admins/mods see all, users see all published."""
|
||||
if current_user.is_moderator:
|
||||
quizzes = db.query(Quiz).order_by(Quiz.created_at.desc()).all()
|
||||
else:
|
||||
quizzes = db.query(Quiz).order_by(Quiz.created_at.desc()).all()
|
||||
return quizzes
|
||||
|
||||
|
||||
@router.get("/{quiz_id}")
|
||||
def get_quiz(
|
||||
quiz_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get quiz for taking. Learning mode includes answers."""
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
|
||||
if quiz.mode == "learning":
|
||||
return QuizLearningDetail.model_validate(quiz)
|
||||
return QuizDetail.model_validate(quiz)
|
||||
|
||||
|
||||
@router.get("/{quiz_id}/review", response_model=QuizReview)
|
||||
def review_quiz(
|
||||
quiz_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get quiz with answers — only if user has completed an attempt."""
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
|
||||
has_attempt = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.quiz_id == quiz_id,
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.isnot(None),
|
||||
).first()
|
||||
if not has_attempt and not current_user.is_moderator:
|
||||
raise HTTPException(status_code=403, detail="Complete an attempt first to review answers")
|
||||
|
||||
return quiz
|
||||
|
||||
|
||||
@router.delete("/{quiz_id}", status_code=204)
|
||||
def delete_quiz(
|
||||
quiz_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
db.delete(quiz)
|
||||
db.commit()
|
||||
38
backend/app/routers/tts.py
Normal file
38
backend/app/routers/tts.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.services import ai_service
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TTSRequest(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
@router.post("/speak")
|
||||
def text_to_speech(
|
||||
request: TTSRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Convert text to speech using configured TTS model."""
|
||||
if not request.text.strip():
|
||||
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
||||
|
||||
# Limit text length
|
||||
text = request.text[:2000]
|
||||
|
||||
# Get TTS model config
|
||||
model_id, api_key = ai_service.get_model_for_task(db, "tts")
|
||||
|
||||
audio = ai_service.generate_tts_audio(text, model_id=model_id, api_key=api_key)
|
||||
if audio is None:
|
||||
raise HTTPException(status_code=500, detail="TTS generation failed. Check model configuration.")
|
||||
|
||||
return Response(content=audio, media_type="audio/mpeg")
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
37
backend/app/schemas/admin.py
Normal file
37
backend/app/schemas/admin.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AIModelConfigCreate(BaseModel):
|
||||
model_config = {"protected_namespaces": ()}
|
||||
name: str
|
||||
model_id: str
|
||||
task: str # extraction, tts, general
|
||||
api_key: str | None = None
|
||||
is_active: bool = True
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class AIModelConfigResponse(BaseModel):
|
||||
model_config = {"protected_namespaces": ()}
|
||||
id: int
|
||||
name: str
|
||||
model_id: str
|
||||
task: str
|
||||
is_active: bool
|
||||
is_default: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AIModelConfigUpdate(BaseModel):
|
||||
model_config = {"protected_namespaces": ()}
|
||||
name: str | None = None
|
||||
model_id: str | None = None
|
||||
task: str | None = None
|
||||
api_key: str | None = None
|
||||
is_active: bool | None = None
|
||||
is_default: bool | None = None
|
||||
60
backend/app/schemas/attempt.py
Normal file
60
backend/app/schemas/attempt.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AnswerSubmission(BaseModel):
|
||||
question_id: int
|
||||
user_answer: str
|
||||
|
||||
|
||||
class AttemptSubmit(BaseModel):
|
||||
answers: list[AnswerSubmission]
|
||||
|
||||
|
||||
class AnswerDetail(BaseModel):
|
||||
question_id: int
|
||||
question_text: str
|
||||
question_type: str
|
||||
user_answer: str
|
||||
correct_answer: str
|
||||
is_correct: bool
|
||||
explanation: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AttemptResponse(BaseModel):
|
||||
id: int
|
||||
quiz_id: int
|
||||
score: int
|
||||
total_questions: int
|
||||
percentage: float
|
||||
started_at: datetime
|
||||
completed_at: datetime | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AttemptDetail(AttemptResponse):
|
||||
answers: list[AnswerDetail] = []
|
||||
|
||||
|
||||
# Stats schemas
|
||||
class QuizStats(BaseModel):
|
||||
quiz_id: int
|
||||
quiz_title: str
|
||||
attempts_count: int
|
||||
best_score: float
|
||||
latest_score: float
|
||||
average_score: float
|
||||
|
||||
|
||||
class DashboardStats(BaseModel):
|
||||
total_documents: int
|
||||
total_quizzes: int
|
||||
total_attempts: int
|
||||
average_score: float
|
||||
quiz_stats: list[QuizStats] = []
|
||||
34
backend/app/schemas/auth.py
Normal file
34
backend/app/schemas/auth.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
name: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserUpdateRole(BaseModel):
|
||||
role: str # admin, moderator, user
|
||||
55
backend/app/schemas/document.py
Normal file
55
backend/app/schemas/document.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class SectionCreate(BaseModel):
|
||||
name: str
|
||||
start_page: int
|
||||
end_page: int
|
||||
|
||||
@field_validator("end_page")
|
||||
@classmethod
|
||||
def end_after_start(cls, v, info):
|
||||
if "start_page" in info.data and v <= info.data["start_page"]:
|
||||
raise ValueError("end_page must be greater than start_page")
|
||||
return v
|
||||
|
||||
@field_validator("start_page")
|
||||
@classmethod
|
||||
def start_positive(cls, v):
|
||||
if v < 1:
|
||||
raise ValueError("start_page must be at least 1")
|
||||
return v
|
||||
|
||||
|
||||
class SectionResponse(BaseModel):
|
||||
id: int
|
||||
document_id: int
|
||||
name: str
|
||||
start_page: int
|
||||
end_page: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DocumentResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
original_filename: str
|
||||
total_pages: int | None
|
||||
status: str
|
||||
error_message: str | None
|
||||
uploaded_at: datetime
|
||||
sections: list[SectionResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DocumentStatusResponse(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
total_pages: int | None
|
||||
error_message: str | None
|
||||
54
backend/app/schemas/quiz.py
Normal file
54
backend/app/schemas/quiz.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class QuizCreate(BaseModel):
|
||||
section_id: int
|
||||
title: str
|
||||
mode: str = "timed" # timed, learning
|
||||
time_limit_minutes: int | None = None
|
||||
|
||||
|
||||
class QuestionResponse(BaseModel):
|
||||
id: int
|
||||
question_text: str
|
||||
question_type: str
|
||||
options: list[str] | None
|
||||
image_path: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class QuestionWithAnswer(QuestionResponse):
|
||||
correct_answer: str
|
||||
explanation: str | None
|
||||
page_reference: int | None
|
||||
|
||||
|
||||
class QuizResponse(BaseModel):
|
||||
id: int
|
||||
section_id: int
|
||||
user_id: int
|
||||
title: str
|
||||
questions_count: int
|
||||
mode: str
|
||||
time_limit_minutes: int | None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class QuizDetail(QuizResponse):
|
||||
questions: list[QuestionResponse] = []
|
||||
|
||||
|
||||
class QuizLearningDetail(QuizResponse):
|
||||
"""Learning mode — includes answers and explanations."""
|
||||
questions: list[QuestionWithAnswer] = []
|
||||
|
||||
|
||||
class QuizReview(QuizResponse):
|
||||
questions: list[QuestionWithAnswer] = []
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
162
backend/app/services/ai_service.py
Normal file
162
backend/app/services/ai_service.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import json
|
||||
import logging
|
||||
|
||||
import litellm
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXTRACTION_PROMPT = """You are a quiz question extractor. The following content is from a PDF that contains quiz/exam questions with answers and explanations.
|
||||
|
||||
Your job is to EXTRACT (not generate) all the questions, their options, correct answers, and explanations exactly as they appear in the content.
|
||||
|
||||
Return a JSON object with a "questions" key containing an array where each object has:
|
||||
- "question_text": the full question text as it appears
|
||||
- "question_type": "mcq" if it has multiple choice options, "true_false" if True/False, "fill_blank" if fill-in-the-blank
|
||||
- "options": array of option strings exactly as written (for mcq), ["True", "False"] (for true_false), or null (for fill_blank)
|
||||
- "correct_answer": the correct answer exactly as marked in the source
|
||||
- "explanation": the explanation text if provided, or "" if none
|
||||
- "page_reference": {page_ref}
|
||||
|
||||
Important:
|
||||
- Extract ALL questions found in the content — do not skip any
|
||||
- Preserve the original wording exactly
|
||||
- If a question has an image reference, include "[IMAGE]" in the question_text where the image would be
|
||||
- If no clear correct answer is marked, use the best answer based on the explanation
|
||||
|
||||
Content from page(s) {page_info}:
|
||||
{content}
|
||||
|
||||
Return ONLY valid JSON. No markdown formatting."""
|
||||
|
||||
|
||||
def get_model_for_task(db, task: str = "extraction") -> tuple[str, str | None]:
|
||||
"""Get the configured model for a specific task from DB, or fall back to settings."""
|
||||
try:
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
config = db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == task,
|
||||
AIModelConfig.is_active == True,
|
||||
AIModelConfig.is_default == True,
|
||||
).first()
|
||||
if config:
|
||||
return config.model_id, config.api_key
|
||||
except Exception:
|
||||
pass
|
||||
return settings.LITELLM_MODEL, settings.LITELLM_API_KEY or None
|
||||
|
||||
|
||||
def _truncate_content(content: str, max_chars: int = 100000) -> str:
|
||||
if len(content) <= max_chars:
|
||||
return content
|
||||
half = max_chars // 2
|
||||
return content[:half] + "\n\n... [content truncated] ...\n\n" + content[-half:]
|
||||
|
||||
|
||||
def extract_questions(
|
||||
content: str,
|
||||
page_info: str = "unknown",
|
||||
page_ref: int | None = None,
|
||||
model_id: str | None = None,
|
||||
api_key: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Extract quiz questions from PDF content using LiteLLM."""
|
||||
content = _truncate_content(content)
|
||||
|
||||
prompt = EXTRACTION_PROMPT.format(
|
||||
content=content,
|
||||
page_info=page_info,
|
||||
page_ref=page_ref if page_ref else "null",
|
||||
)
|
||||
|
||||
use_model = model_id or settings.LITELLM_MODEL
|
||||
use_key = api_key or settings.LITELLM_API_KEY
|
||||
|
||||
last_error = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
kwargs = {
|
||||
"model": use_model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.1, # low temp for faithful extraction
|
||||
}
|
||||
if use_key:
|
||||
kwargs["api_key"] = use_key
|
||||
|
||||
# Try JSON mode if supported
|
||||
try:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
response = litellm.completion(**kwargs)
|
||||
except Exception:
|
||||
kwargs.pop("response_format", None)
|
||||
response = litellm.completion(**kwargs)
|
||||
|
||||
response_text = response.choices[0].message.content
|
||||
|
||||
# Try to parse JSON, handle markdown code blocks
|
||||
text = response_text.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
text = text.strip()
|
||||
|
||||
data = json.loads(text)
|
||||
questions = data.get("questions", data) if isinstance(data, dict) else data
|
||||
if not isinstance(questions, list):
|
||||
raise ValueError("Response is not a list of questions")
|
||||
|
||||
validated = []
|
||||
for q in questions:
|
||||
if not all(k in q for k in ("question_text", "correct_answer")):
|
||||
continue
|
||||
qtype = q.get("question_type", "mcq")
|
||||
if qtype not in ("mcq", "true_false", "fill_blank"):
|
||||
qtype = "mcq"
|
||||
validated.append({
|
||||
"question_text": q["question_text"],
|
||||
"question_type": qtype,
|
||||
"options": q.get("options"),
|
||||
"correct_answer": q["correct_answer"],
|
||||
"explanation": q.get("explanation", ""),
|
||||
"page_reference": q.get("page_reference"),
|
||||
})
|
||||
|
||||
if validated:
|
||||
return validated
|
||||
|
||||
raise ValueError("No valid questions extracted from content")
|
||||
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(f"Extraction attempt {attempt + 1} failed: {e}")
|
||||
|
||||
raise RuntimeError(f"Failed to extract questions after 3 attempts: {last_error}")
|
||||
|
||||
|
||||
def generate_tts_audio(
|
||||
text: str,
|
||||
model_id: str | None = None,
|
||||
api_key: str | None = None,
|
||||
) -> bytes | None:
|
||||
"""Generate TTS audio using LiteLLM (supports Google Vertex, OpenAI, etc.)."""
|
||||
use_model = model_id or "vertex_ai/google/cloud-tts"
|
||||
use_key = api_key or settings.LITELLM_API_KEY
|
||||
|
||||
try:
|
||||
# LiteLLM speech endpoint
|
||||
response = litellm.speech(
|
||||
model=use_model,
|
||||
input=text,
|
||||
api_key=use_key if use_key else None,
|
||||
)
|
||||
# Response is audio bytes
|
||||
if hasattr(response, "content"):
|
||||
return response.content
|
||||
if hasattr(response, "read"):
|
||||
return response.read()
|
||||
return bytes(response)
|
||||
except Exception as e:
|
||||
logger.error(f"TTS generation failed: {e}")
|
||||
return None
|
||||
64
backend/app/services/email_service.py
Normal file
64
backend/app/services/email_service.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import logging
|
||||
|
||||
from fastapi_mail import FastMail, MessageSchema, MessageType, ConnectionConfig
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_mail_config() -> ConnectionConfig:
|
||||
return ConnectionConfig(
|
||||
MAIL_USERNAME=settings.MAIL_USERNAME,
|
||||
MAIL_PASSWORD=settings.MAIL_PASSWORD,
|
||||
MAIL_FROM=settings.MAIL_FROM,
|
||||
MAIL_PORT=settings.MAIL_PORT,
|
||||
MAIL_SERVER=settings.MAIL_SERVER,
|
||||
MAIL_STARTTLS=settings.MAIL_STARTTLS,
|
||||
MAIL_SSL_TLS=settings.MAIL_SSL_TLS,
|
||||
USE_CREDENTIALS=bool(settings.MAIL_USERNAME and settings.MAIL_PASSWORD),
|
||||
)
|
||||
|
||||
|
||||
async def send_reminder_email(
|
||||
email: str,
|
||||
user_name: str,
|
||||
quiz_title: str,
|
||||
score: float,
|
||||
next_date: str,
|
||||
):
|
||||
"""Send a spaced-repetition reminder email."""
|
||||
html = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #2563eb;">Quiz Reminder</h2>
|
||||
<p>Hi {user_name},</p>
|
||||
<p>It's time to review <strong>{quiz_title}</strong>!</p>
|
||||
<p>Your last score was <strong>{score:.0f}%</strong>.
|
||||
{'Great progress! Keep it up.' if score >= 70 else 'Practice makes perfect — give it another try!'}</p>
|
||||
<div style="background: #f3f4f6; padding: 16px; border-radius: 8px; margin: 16px 0;">
|
||||
<p style="margin: 0;"><strong>Tip:</strong> Spaced repetition helps you remember more over time.
|
||||
Each review strengthens your memory!</p>
|
||||
</div>
|
||||
<p>Log in to take the quiz again.</p>
|
||||
<p style="color: #6b7280; font-size: 12px;">
|
||||
You're receiving this because you have active quiz reminders.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
message = MessageSchema(
|
||||
subject=f"Quiz Reminder: {quiz_title}",
|
||||
recipients=[email],
|
||||
body=html,
|
||||
subtype=MessageType.html,
|
||||
)
|
||||
|
||||
try:
|
||||
conf = get_mail_config()
|
||||
fm = FastMail(conf)
|
||||
await fm.send_message(message)
|
||||
logger.info(f"Reminder sent to {email} for quiz '{quiz_title}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send reminder to {email}: {e}")
|
||||
103
backend/app/services/pdf_service.py
Normal file
103
backend/app/services/pdf_service.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import os
|
||||
import logging
|
||||
|
||||
import fitz # PyMuPDF
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_page_count(file_path: str) -> int:
|
||||
doc = fitz.open(file_path)
|
||||
count = len(doc)
|
||||
doc.close()
|
||||
return count
|
||||
|
||||
|
||||
def extract_text_by_page(file_path: str) -> dict[int, str]:
|
||||
"""Extract text from every page. Returns {page_num: text} (1-indexed)."""
|
||||
doc = fitz.open(file_path)
|
||||
pages = {}
|
||||
for i in range(len(doc)):
|
||||
text = doc[i].get_text()
|
||||
if text.strip():
|
||||
pages[i + 1] = text
|
||||
doc.close()
|
||||
return pages
|
||||
|
||||
|
||||
def extract_text_for_range(file_path: str, start: int, end: int) -> str:
|
||||
"""Extract text for a page range (1-indexed, inclusive)."""
|
||||
doc = fitz.open(file_path)
|
||||
texts = []
|
||||
for i in range(start - 1, min(end, len(doc))):
|
||||
text = doc[i].get_text()
|
||||
if text.strip():
|
||||
texts.append(f"--- Page {i + 1} ---\n{text}")
|
||||
doc.close()
|
||||
return "\n\n".join(texts)
|
||||
|
||||
|
||||
def extract_images_from_page(file_path: str, page_num: int, document_id: int) -> list[str]:
|
||||
"""Extract images from a specific page. Returns list of saved image paths."""
|
||||
image_dir = os.path.join(settings.UPLOAD_DIR, "images", f"doc_{document_id}")
|
||||
os.makedirs(image_dir, exist_ok=True)
|
||||
|
||||
saved_paths = []
|
||||
try:
|
||||
doc = fitz.open(file_path)
|
||||
if page_num < 1 or page_num > len(doc):
|
||||
doc.close()
|
||||
return []
|
||||
|
||||
page = doc[page_num - 1]
|
||||
images = page.get_images(full=True)
|
||||
|
||||
for img_idx, img_info in enumerate(images):
|
||||
xref = img_info[0]
|
||||
try:
|
||||
base_image = doc.extract_image(xref)
|
||||
if not base_image:
|
||||
continue
|
||||
|
||||
image_ext = base_image.get("ext", "png")
|
||||
image_bytes = base_image["image"]
|
||||
|
||||
# Skip tiny images (likely icons/bullets)
|
||||
if len(image_bytes) < 1000:
|
||||
continue
|
||||
|
||||
filename = f"page_{page_num}_img_{img_idx}.{image_ext}"
|
||||
filepath = os.path.join(image_dir, filename)
|
||||
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
# Return relative path for serving
|
||||
rel_path = f"images/doc_{document_id}/{filename}"
|
||||
saved_paths.append(rel_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract image {img_idx} from page {page_num}: {e}")
|
||||
continue
|
||||
|
||||
doc.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Image extraction failed for page {page_num}: {e}")
|
||||
|
||||
return saved_paths
|
||||
|
||||
|
||||
def extract_all_images(file_path: str, document_id: int, start_page: int = 1, end_page: int | None = None) -> dict[int, list[str]]:
|
||||
"""Extract images from a page range. Returns {page_num: [image_paths]}."""
|
||||
doc = fitz.open(file_path)
|
||||
if end_page is None:
|
||||
end_page = len(doc)
|
||||
doc.close()
|
||||
|
||||
result = {}
|
||||
for page_num in range(start_page, end_page + 1):
|
||||
images = extract_images_from_page(file_path, page_num, document_id)
|
||||
if images:
|
||||
result[page_num] = images
|
||||
return result
|
||||
104
backend/app/services/quiz_service.py
Normal file
104
backend/app/services/quiz_service.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import os
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models.section import Section
|
||||
from app.models.pdf_document import PDFDocument
|
||||
from app.models.quiz import Quiz
|
||||
from app.models.question import Question
|
||||
from app.services import ai_service, vector_service, pdf_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_quiz_from_section(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
section_id: int,
|
||||
title: str,
|
||||
mode: str = "timed",
|
||||
time_limit_minutes: int | None = None,
|
||||
) -> Quiz:
|
||||
"""Extract questions from a section's page range using AI."""
|
||||
section = db.query(Section).filter(Section.id == section_id).first()
|
||||
if not section:
|
||||
raise ValueError("Section not found")
|
||||
|
||||
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
|
||||
if not document:
|
||||
raise ValueError("Document not found")
|
||||
|
||||
# Get text from vector store for this page range
|
||||
content = vector_service.get_pages_text(
|
||||
document_id=section.document_id,
|
||||
start_page=section.start_page,
|
||||
end_page=section.end_page,
|
||||
)
|
||||
|
||||
if not content:
|
||||
raise ValueError("No content found for this section's page range")
|
||||
|
||||
# Get configured model
|
||||
model_id, api_key = ai_service.get_model_for_task(db, "extraction")
|
||||
|
||||
# Extract questions via AI (not generate — questions already exist in PDF)
|
||||
page_info = f"{section.start_page}-{section.end_page}"
|
||||
question_data = ai_service.extract_questions(
|
||||
content,
|
||||
page_info=page_info,
|
||||
model_id=model_id,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
# Extract images for the page range
|
||||
file_path = os.path.join(settings.UPLOAD_DIR, document.filename)
|
||||
page_images = {}
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
page_images = pdf_service.extract_all_images(
|
||||
file_path, document.id, section.start_page, section.end_page
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Image extraction failed: {e}")
|
||||
|
||||
# Create quiz
|
||||
quiz = Quiz(
|
||||
section_id=section_id,
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
questions_count=len(question_data),
|
||||
mode=mode,
|
||||
time_limit_minutes=time_limit_minutes,
|
||||
)
|
||||
db.add(quiz)
|
||||
db.flush()
|
||||
|
||||
# Create question records, associating images where possible
|
||||
for q in question_data:
|
||||
page_ref = q.get("page_reference")
|
||||
image_path = None
|
||||
|
||||
# Try to associate an image with this question
|
||||
if page_ref and page_ref in page_images and page_images[page_ref]:
|
||||
# Take the first unassigned image from this page
|
||||
image_path = page_images[page_ref].pop(0)
|
||||
if not page_images[page_ref]:
|
||||
del page_images[page_ref]
|
||||
|
||||
question = Question(
|
||||
quiz_id=quiz.id,
|
||||
question_text=q["question_text"],
|
||||
question_type=q["question_type"],
|
||||
options=q.get("options"),
|
||||
correct_answer=q["correct_answer"],
|
||||
explanation=q.get("explanation", ""),
|
||||
page_reference=page_ref,
|
||||
image_path=image_path,
|
||||
)
|
||||
db.add(question)
|
||||
|
||||
db.commit()
|
||||
db.refresh(quiz)
|
||||
return quiz
|
||||
61
backend/app/services/reminder_service.py
Normal file
61
backend/app/services/reminder_service.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.reminder import ReminderSchedule
|
||||
|
||||
# SM-2 simplified intervals in days
|
||||
INTERVALS = [1, 3, 7, 14, 30]
|
||||
|
||||
|
||||
def update_reminder_schedule(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
quiz_id: int,
|
||||
score_percentage: float,
|
||||
):
|
||||
"""Update or create a reminder schedule based on quiz performance."""
|
||||
reminder = db.query(ReminderSchedule).filter(
|
||||
ReminderSchedule.user_id == user_id,
|
||||
ReminderSchedule.quiz_id == quiz_id,
|
||||
).first()
|
||||
|
||||
if not reminder:
|
||||
reminder = ReminderSchedule(
|
||||
user_id=user_id,
|
||||
quiz_id=quiz_id,
|
||||
performance_score=score_percentage,
|
||||
interval_days=INTERVALS[0],
|
||||
next_reminder_at=datetime.utcnow() + timedelta(days=INTERVALS[0]),
|
||||
is_active=True,
|
||||
)
|
||||
db.add(reminder)
|
||||
else:
|
||||
reminder.performance_score = score_percentage
|
||||
reminder.is_active = True
|
||||
|
||||
# Find current interval index
|
||||
current_idx = 0
|
||||
for i, interval in enumerate(INTERVALS):
|
||||
if reminder.interval_days <= interval:
|
||||
current_idx = i
|
||||
break
|
||||
|
||||
if score_percentage < 70:
|
||||
# Poor performance: reset to shortest interval
|
||||
new_idx = 0
|
||||
elif score_percentage < 90:
|
||||
# Decent performance: advance one step
|
||||
new_idx = min(current_idx + 1, len(INTERVALS) - 1)
|
||||
else:
|
||||
# Excellent performance: advance or deactivate
|
||||
if current_idx >= len(INTERVALS) - 1:
|
||||
reminder.is_active = False
|
||||
db.commit()
|
||||
return
|
||||
new_idx = min(current_idx + 2, len(INTERVALS) - 1)
|
||||
|
||||
reminder.interval_days = INTERVALS[new_idx]
|
||||
reminder.next_reminder_at = datetime.utcnow() + timedelta(days=INTERVALS[new_idx])
|
||||
|
||||
db.commit()
|
||||
145
backend/app/services/vector_service.py
Normal file
145
backend/app/services/vector_service.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import chromadb
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def get_client() -> chromadb.PersistentClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = chromadb.PersistentClient(path=settings.CHROMA_PERSIST_DIR)
|
||||
return _client
|
||||
|
||||
|
||||
def get_or_create_collection(document_id: int):
|
||||
client = get_client()
|
||||
return client.get_or_create_collection(name=f"doc_{document_id}")
|
||||
|
||||
|
||||
def delete_collection(document_id: int):
|
||||
client = get_client()
|
||||
try:
|
||||
client.delete_collection(name=f"doc_{document_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def chunk_text(text: str, chunk_size: int = 1000, overlap: int = 200) -> list[str]:
|
||||
"""Split text into overlapping chunks."""
|
||||
if len(text) <= chunk_size:
|
||||
return [text]
|
||||
chunks = []
|
||||
start = 0
|
||||
while start < len(text):
|
||||
end = start + chunk_size
|
||||
chunks.append(text[start:end])
|
||||
start = end - overlap
|
||||
return chunks
|
||||
|
||||
|
||||
def store_pages(document_id: int, pages: dict[int, str]):
|
||||
"""Store page text as chunked embeddings in ChromaDB."""
|
||||
collection = get_or_create_collection(document_id)
|
||||
|
||||
all_ids = []
|
||||
all_docs = []
|
||||
all_metadatas = []
|
||||
|
||||
for page_num, text in pages.items():
|
||||
chunks = chunk_text(text)
|
||||
for i, chunk in enumerate(chunks):
|
||||
doc_id = f"doc_{document_id}_page_{page_num}_chunk_{i}"
|
||||
all_ids.append(doc_id)
|
||||
all_docs.append(chunk)
|
||||
all_metadatas.append({"page_num": page_num, "document_id": document_id})
|
||||
|
||||
# ChromaDB has a batch limit; add in batches of 500
|
||||
batch_size = 500
|
||||
for i in range(0, len(all_ids), batch_size):
|
||||
collection.add(
|
||||
ids=all_ids[i:i + batch_size],
|
||||
documents=all_docs[i:i + batch_size],
|
||||
metadatas=all_metadatas[i:i + batch_size],
|
||||
)
|
||||
|
||||
|
||||
def query_pages(
|
||||
document_id: int,
|
||||
query: str,
|
||||
start_page: int | None = None,
|
||||
end_page: int | None = None,
|
||||
n_results: int = 20,
|
||||
) -> list[dict]:
|
||||
"""Query vectorized content with optional page range filter."""
|
||||
collection = get_or_create_collection(document_id)
|
||||
|
||||
where_filter = None
|
||||
if start_page is not None and end_page is not None:
|
||||
where_filter = {
|
||||
"$and": [
|
||||
{"page_num": {"$gte": start_page}},
|
||||
{"page_num": {"$lte": end_page}},
|
||||
]
|
||||
}
|
||||
|
||||
results = collection.query(
|
||||
query_texts=[query],
|
||||
n_results=n_results,
|
||||
where=where_filter,
|
||||
)
|
||||
|
||||
docs = []
|
||||
if results and results["documents"]:
|
||||
for i, doc in enumerate(results["documents"][0]):
|
||||
meta = results["metadatas"][0][i] if results["metadatas"] else {}
|
||||
docs.append({"text": doc, "page_num": meta.get("page_num")})
|
||||
return docs
|
||||
|
||||
|
||||
def get_pages_text(document_id: int, start_page: int, end_page: int) -> str:
|
||||
"""Retrieve all stored text for a page range, ordered by page number."""
|
||||
collection = get_or_create_collection(document_id)
|
||||
|
||||
where_filter = {
|
||||
"$and": [
|
||||
{"page_num": {"$gte": start_page}},
|
||||
{"page_num": {"$lte": end_page}},
|
||||
]
|
||||
}
|
||||
|
||||
# Get all documents in the range
|
||||
results = collection.get(
|
||||
where=where_filter,
|
||||
include=["documents", "metadatas"],
|
||||
)
|
||||
|
||||
if not results or not results["documents"]:
|
||||
return ""
|
||||
|
||||
# Sort by page number and chunk order
|
||||
paired = list(zip(results["documents"], results["metadatas"], results["ids"]))
|
||||
paired.sort(key=lambda x: (x[1].get("page_num", 0), x[2]))
|
||||
|
||||
# Deduplicate overlapping chunks per page
|
||||
seen_pages = {}
|
||||
for doc, meta, doc_id in paired:
|
||||
page = meta.get("page_num", 0)
|
||||
if page not in seen_pages:
|
||||
seen_pages[page] = []
|
||||
seen_pages[page].append(doc)
|
||||
|
||||
texts = []
|
||||
for page in sorted(seen_pages.keys()):
|
||||
# Join chunks for each page, removing overlap duplicates
|
||||
page_text = seen_pages[page][0]
|
||||
for chunk in seen_pages[page][1:]:
|
||||
# Find overlap and append only new content
|
||||
overlap_len = 200
|
||||
if len(chunk) > overlap_len:
|
||||
page_text += chunk[overlap_len:]
|
||||
else:
|
||||
page_text += chunk
|
||||
texts.append(f"--- Page {page} ---\n{page_text}")
|
||||
|
||||
return "\n\n".join(texts)
|
||||
12
backend/app/tasks/__init__.py
Normal file
12
backend/app/tasks/__init__.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from celery import Celery
|
||||
|
||||
from app.config import settings
|
||||
|
||||
celery_app = Celery(
|
||||
"quiz_tasks",
|
||||
broker=settings.REDIS_URL,
|
||||
backend=settings.REDIS_URL,
|
||||
)
|
||||
celery_app.conf.task_serializer = "json"
|
||||
celery_app.conf.result_serializer = "json"
|
||||
celery_app.conf.accept_content = ["json"]
|
||||
53
backend/app/tasks/pdf_tasks.py
Normal file
53
backend/app/tasks/pdf_tasks.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import logging
|
||||
|
||||
from app.tasks import celery_app
|
||||
from app.database import SessionLocal
|
||||
from app.models.pdf_document import PDFDocument
|
||||
from app.services import pdf_service, vector_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(name="process_pdf")
|
||||
def process_pdf(document_id: int, file_path: str):
|
||||
"""Background task: extract PDF text and store in ChromaDB."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
doc = db.query(PDFDocument).filter(PDFDocument.id == document_id).first()
|
||||
if not doc:
|
||||
logger.error(f"Document {document_id} not found")
|
||||
return
|
||||
|
||||
# Get page count
|
||||
total_pages = pdf_service.get_page_count(file_path)
|
||||
doc.total_pages = total_pages
|
||||
db.commit()
|
||||
|
||||
# Extract text from all pages
|
||||
pages = pdf_service.extract_text_by_page(file_path)
|
||||
if not pages:
|
||||
doc.status = "error"
|
||||
doc.error_message = "No text could be extracted from the PDF"
|
||||
db.commit()
|
||||
return
|
||||
|
||||
# Store in ChromaDB
|
||||
vector_service.store_pages(document_id, pages)
|
||||
|
||||
# Mark as ready
|
||||
doc.status = "ready"
|
||||
db.commit()
|
||||
logger.info(f"Document {document_id} processed: {total_pages} pages, {len(pages)} with text")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error processing document {document_id}")
|
||||
try:
|
||||
doc = db.query(PDFDocument).filter(PDFDocument.id == document_id).first()
|
||||
if doc:
|
||||
doc.status = "error"
|
||||
doc.error_message = str(e)[:500]
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
64
backend/app/utils/auth.py
Normal file
64
backend/app/utils/auth.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
email: str = payload.get("sub")
|
||||
if email is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
|
||||
def require_moderator(current_user: User = Depends(get_current_user)) -> User:
|
||||
if not current_user.is_moderator:
|
||||
raise HTTPException(status_code=403, detail="Moderator access required")
|
||||
return current_user
|
||||
82
backend/app/utils/scheduler.py
Normal file
82
backend/app/utils/scheduler.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.reminder import ReminderSchedule
|
||||
from app.models.user import User
|
||||
from app.models.quiz import Quiz
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
|
||||
def check_and_send_reminders():
|
||||
"""Check for due reminders and send emails."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
due_reminders = db.query(ReminderSchedule).filter(
|
||||
ReminderSchedule.is_active == True,
|
||||
ReminderSchedule.next_reminder_at <= datetime.utcnow(),
|
||||
).all()
|
||||
|
||||
if not due_reminders:
|
||||
return
|
||||
|
||||
logger.info(f"Found {len(due_reminders)} due reminders")
|
||||
|
||||
for reminder in due_reminders:
|
||||
user = db.query(User).filter(User.id == reminder.user_id).first()
|
||||
quiz = db.query(Quiz).filter(Quiz.id == reminder.quiz_id).first()
|
||||
|
||||
if not user or not quiz:
|
||||
reminder.is_active = False
|
||||
continue
|
||||
|
||||
# Send email asynchronously
|
||||
try:
|
||||
from app.services.email_service import send_reminder_email
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.run_until_complete(
|
||||
send_reminder_email(
|
||||
email=user.email,
|
||||
user_name=user.name,
|
||||
quiz_title=quiz.title,
|
||||
score=reminder.performance_score,
|
||||
next_date=reminder.next_reminder_at.strftime("%Y-%m-%d"),
|
||||
)
|
||||
)
|
||||
loop.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send reminder {reminder.id}: {e}")
|
||||
|
||||
# Schedule next reminder
|
||||
from datetime import timedelta
|
||||
reminder.next_reminder_at = datetime.utcnow() + timedelta(days=reminder.interval_days)
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.exception(f"Scheduler error: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""Start the APScheduler with daily reminder check."""
|
||||
scheduler.add_job(
|
||||
check_and_send_reminders,
|
||||
"interval",
|
||||
hours=24,
|
||||
id="reminder_check",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started — reminder check runs every 24 hours")
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
if scheduler.running:
|
||||
scheduler.shutdown()
|
||||
19
backend/requirements.txt
Normal file
19
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
fastapi==0.109.2
|
||||
uvicorn[standard]==0.27.1
|
||||
sqlalchemy==2.0.27
|
||||
alembic==1.13.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.9
|
||||
pydantic[email]==2.6.1
|
||||
pydantic-settings==2.1.0
|
||||
PyMuPDF==1.23.22
|
||||
litellm==1.27.10
|
||||
chromadb==0.4.24
|
||||
celery[redis]==5.3.6
|
||||
redis==5.0.1
|
||||
fastapi-mail==1.4.1
|
||||
apscheduler==3.10.4
|
||||
aiofiles==23.2.1
|
||||
python-dotenv==1.0.1
|
||||
httpx==0.27.0
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
53
docker-compose.yml
Normal file
53
docker-compose.yml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- chroma_data:/app/chroma_data
|
||||
- sqlite_data:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:////app/data/quiz.db
|
||||
depends_on:
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
|
||||
celery:
|
||||
build: ./backend
|
||||
command: celery -A app.tasks worker --loglevel=info --concurrency=2
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- chroma_data:/app/chroma_data
|
||||
- sqlite_data:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:////app/data/quiz.db
|
||||
depends_on:
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
uploads_data:
|
||||
chroma_data:
|
||||
sqlite_data:
|
||||
redis_data:
|
||||
2
frontend/.dockerignore
Normal file
2
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FROM node:18-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PDF Quiz Generator</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
frontend/nginx.conf
Normal file
25
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# API proxy to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Large file uploads
|
||||
client_max_body_size 500M;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "quiz-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.1.0"
|
||||
}
|
||||
}
|
||||
54
frontend/src/App.jsx
Normal file
54
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import Navbar from './components/Navbar'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import RegisterPage from './pages/RegisterPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import UploadPage from './pages/UploadPage'
|
||||
import DocumentDetailPage from './pages/DocumentDetailPage'
|
||||
import QuizPage from './pages/QuizPage'
|
||||
import QuizzesPage from './pages/QuizzesPage'
|
||||
import ResultsPage from './pages/ResultsPage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
|
||||
function ProtectedRoute({ children, requireModerator = false }) {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return <div className="loading"><div className="spinner"></div></div>
|
||||
if (!user) return <Navigate to="/login" />
|
||||
if (requireModerator && user.role !== 'admin' && user.role !== 'moderator') return <Navigate to="/" />
|
||||
return children
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return <div className="loading"><div className="spinner"></div></div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="container">
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/" /> : <LoginPage />} />
|
||||
<Route path="/register" element={user ? <Navigate to="/" /> : <RegisterPage />} />
|
||||
<Route path="/" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
||||
<Route path="/upload" element={<ProtectedRoute requireModerator><UploadPage /></ProtectedRoute>} />
|
||||
<Route path="/documents/:id" element={<ProtectedRoute><DocumentDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/quizzes" element={<ProtectedRoute><QuizzesPage /></ProtectedRoute>} />
|
||||
<Route path="/quizzes/:id" element={<ProtectedRoute><QuizPage /></ProtectedRoute>} />
|
||||
<Route path="/results/:id" element={<ProtectedRoute><ResultsPage /></ProtectedRoute>} />
|
||||
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
26
frontend/src/api/client.js
Normal file
26
frontend/src/api/client.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
36
frontend/src/components/Navbar.jsx
Normal file
36
frontend/src/components/Navbar.jsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function Navbar() {
|
||||
const { user, logout } = useAuth()
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
|
||||
return (
|
||||
<div className="navbar">
|
||||
<div className="container">
|
||||
<Link to="/" className="logo">PDF Quiz</Link>
|
||||
{user && (
|
||||
<nav>
|
||||
<Link to="/">Dashboard</Link>
|
||||
<Link to="/quizzes">Quizzes</Link>
|
||||
{isModerator && <Link to="/upload">Upload PDF</Link>}
|
||||
{user.role === 'admin' && <Link to="/admin" style={{ color: '#fbbf24' }}>Admin</Link>}
|
||||
<span style={{ color: '#94a3b8', fontSize: '0.85rem' }}>
|
||||
{user.name}
|
||||
{user.role !== 'user' && (
|
||||
<span style={{
|
||||
marginLeft: 6, fontSize: '0.7rem',
|
||||
background: user.role === 'admin' ? '#ef4444' : '#7c3aed',
|
||||
color: 'white', padding: '1px 6px', borderRadius: 10
|
||||
}}>
|
||||
{user.role}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button onClick={logout}>Logout</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
frontend/src/context/AuthContext.jsx
Normal file
50
frontend/src/context/AuthContext.jsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import api from '../api/client'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
api.get('/auth/me')
|
||||
.then(res => setUser(res.data))
|
||||
.catch(() => localStorage.removeItem('token'))
|
||||
.finally(() => setLoading(false))
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = async (email, password) => {
|
||||
const res = await api.post('/auth/login', { email, password })
|
||||
localStorage.setItem('token', res.data.access_token)
|
||||
const me = await api.get('/auth/me')
|
||||
setUser(me.data)
|
||||
return me.data
|
||||
}
|
||||
|
||||
const register = async (email, password, name) => {
|
||||
const res = await api.post('/auth/register', { email, password, name })
|
||||
localStorage.setItem('token', res.data.access_token)
|
||||
const me = await api.get('/auth/me')
|
||||
setUser(me.data)
|
||||
return me.data
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token')
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext)
|
||||
418
frontend/src/index.css
Normal file
418
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.navbar {
|
||||
background: #1e293b;
|
||||
color: white;
|
||||
padding: 12px 0;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.navbar .container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar .logo {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #60a5fa;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navbar nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar a {
|
||||
color: #cbd5e1;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.navbar a:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.navbar button {
|
||||
background: transparent;
|
||||
border: 1px solid #475569;
|
||||
color: #cbd5e1;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin-bottom: 16px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 14px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-processing { background: #fef3c7; color: #92400e; }
|
||||
.badge-ready { background: #d1fae5; color: #065f46; }
|
||||
.badge-error { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
/* Quiz question styles */
|
||||
.question-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
border-left: 4px solid #2563eb;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.question-card h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.question-card .options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.question-card .option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.question-card .option:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.question-card .option.selected {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.question-card .option.correct {
|
||||
border-color: #22c55e;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.question-card .option.incorrect {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.question-card input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.explanation {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: #475569;
|
||||
border-left: 3px solid #60a5fa;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Grid layouts */
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
/* Score display */
|
||||
.score-display {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.score-display .score-value {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.score-display .score-value.good { color: #22c55e; }
|
||||
.score-display .score-value.ok { color: #f59e0b; }
|
||||
.score-display .score-value.poor { color: #ef4444; }
|
||||
|
||||
/* Upload area */
|
||||
.upload-area {
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: #2563eb;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.upload-area.dragging {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar {
|
||||
background: #e2e8f0;
|
||||
border-radius: 999px;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.progress-bar .fill {
|
||||
background: #2563eb;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Section list */
|
||||
.section-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Auth pages */
|
||||
.auth-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.auth-card .auth-link {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-card .auth-link a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
249
frontend/src/pages/AdminPage.jsx
Normal file
249
frontend/src/pages/AdminPage.jsx
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
|
||||
const TASKS = ['extraction', 'tts', 'general']
|
||||
const PRESET_MODELS = {
|
||||
extraction: [
|
||||
{ name: 'GPT-4o Mini', model_id: 'gpt-4o-mini' },
|
||||
{ name: 'GPT-4o', model_id: 'gpt-4o' },
|
||||
{ name: 'Claude Sonnet 4.6', model_id: 'anthropic/claude-sonnet-4-6-20250514' },
|
||||
{ name: 'Claude Haiku 4.5', model_id: 'anthropic/claude-haiku-4-5-20251001' },
|
||||
{ name: 'Gemini Pro', model_id: 'gemini/gemini-1.5-pro' },
|
||||
],
|
||||
tts: [
|
||||
{ name: 'Google Vertex TTS', model_id: 'vertex_ai/google/cloud-tts' },
|
||||
{ name: 'OpenAI TTS-1', model_id: 'openai/tts-1' },
|
||||
{ name: 'OpenAI TTS-1 HD', model_id: 'openai/tts-1-hd' },
|
||||
],
|
||||
general: [
|
||||
{ name: 'GPT-4o Mini', model_id: 'gpt-4o-mini' },
|
||||
{ name: 'GPT-4o', model_id: 'gpt-4o' },
|
||||
],
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [tab, setTab] = useState('models')
|
||||
const [users, setUsers] = useState([])
|
||||
const [models, setModels] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false })
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.role || user.role !== 'admin') { navigate('/'); return }
|
||||
loadData()
|
||||
}, [user])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [usersRes, modelsRes] = await Promise.all([
|
||||
api.get('/admin/users'),
|
||||
api.get('/admin/models'),
|
||||
])
|
||||
setUsers(usersRes.data)
|
||||
setModels(modelsRes.data)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to load data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateRole = async (userId, role) => {
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/role`, { role })
|
||||
setSuccess(`Role updated to ${role}`)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to update role')
|
||||
}
|
||||
}
|
||||
|
||||
const createModel = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
try {
|
||||
await api.post('/admin/models', newModel)
|
||||
setSuccess('Model added')
|
||||
setNewModel({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false })
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to add model')
|
||||
}
|
||||
}
|
||||
|
||||
const setDefault = async (modelId) => {
|
||||
try {
|
||||
await api.put(`/admin/models/${modelId}`, { is_default: true })
|
||||
setSuccess('Default model updated')
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to update')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleActive = async (model) => {
|
||||
try {
|
||||
await api.put(`/admin/models/${model.id}`, { is_active: !model.is_active })
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to update')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteModel = async (modelId) => {
|
||||
if (!confirm('Delete this model config?')) return
|
||||
try {
|
||||
await api.delete(`/admin/models/${modelId}`)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to delete')
|
||||
}
|
||||
}
|
||||
|
||||
const applyPreset = (preset) => {
|
||||
setNewModel(m => ({ ...m, name: preset.name, model_id: preset.model_id }))
|
||||
}
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
|
||||
|
||||
const modelsByTask = TASKS.reduce((acc, t) => {
|
||||
acc[t] = models.filter(m => m.task === t)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<h2>Admin Dashboard</h2>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
{['models', 'users'].map(t => (
|
||||
<button key={t} className={`btn ${tab === t ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t)}>
|
||||
{t === 'models' ? 'AI Models' : 'Users'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{success && <div className="alert alert-success">{success}</div>}
|
||||
|
||||
{tab === 'models' && (
|
||||
<>
|
||||
{TASKS.map(task => (
|
||||
<div className="card" key={task}>
|
||||
<h2 style={{ textTransform: 'capitalize', marginBottom: 12 }}>
|
||||
{task} Models
|
||||
<span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}>
|
||||
{task === 'extraction' ? '— AI that extracts questions from PDFs' :
|
||||
task === 'tts' ? '— Text-to-speech for reading questions' :
|
||||
'— General purpose AI'}
|
||||
</span>
|
||||
</h2>
|
||||
{modelsByTask[task].length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: 16 }}>No models configured</div>
|
||||
) : (
|
||||
modelsByTask[task].map(m => (
|
||||
<div className="section-item" key={m.id}>
|
||||
<div>
|
||||
<strong>{m.name}</strong>
|
||||
<div style={{ fontSize: '0.82rem', color: '#64748b', fontFamily: 'monospace' }}>{m.model_id}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{m.is_default && <span className="badge badge-ready">Default</span>}
|
||||
{!m.is_default && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setDefault(m.id)}>Set Default</button>
|
||||
)}
|
||||
<button
|
||||
className={`btn btn-sm ${m.is_active ? 'btn-secondary' : 'btn-primary'}`}
|
||||
onClick={() => toggleActive(m)}
|
||||
>{m.is_active ? 'Disable' : 'Enable'}</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => deleteModel(m.id)}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="card">
|
||||
<h2>Add Model</h2>
|
||||
<form onSubmit={createModel}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Task</label>
|
||||
<select value={newModel.task} onChange={e => setNewModel(m => ({ ...m, task: e.target.value }))}>
|
||||
{TASKS.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Preset (optional)</label>
|
||||
<select onChange={e => { if (e.target.value) applyPreset(JSON.parse(e.target.value)) }}>
|
||||
<option value="">— select preset —</option>
|
||||
{(PRESET_MODELS[newModel.task] || []).map(p => (
|
||||
<option key={p.model_id} value={JSON.stringify(p)}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Display Name</label>
|
||||
<input type="text" value={newModel.name} onChange={e => setNewModel(m => ({ ...m, name: e.target.value }))} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Model ID (LiteLLM format)</label>
|
||||
<input type="text" value={newModel.model_id} onChange={e => setNewModel(m => ({ ...m, model_id: e.target.value }))} placeholder="e.g. gpt-4o-mini" required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>API Key (optional override)</label>
|
||||
<input type="password" value={newModel.api_key} onChange={e => setNewModel(m => ({ ...m, api_key: e.target.value }))} placeholder="Leave blank to use .env key" />
|
||||
</div>
|
||||
<div className="form-group" style={{ display: 'flex', gap: 16, alignItems: 'center', marginTop: 24 }}>
|
||||
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="checkbox" checked={newModel.is_default} onChange={e => setNewModel(m => ({ ...m, is_default: e.target.checked }))} />
|
||||
Set as default
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" type="submit">Add Model</button>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'users' && (
|
||||
<div className="card">
|
||||
<h2>Users</h2>
|
||||
{users.map(u => (
|
||||
<div className="section-item" key={u.id}>
|
||||
<div>
|
||||
<strong>{u.name}</strong>
|
||||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>{u.email} · joined {new Date(u.created_at).toLocaleDateString()}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span className={`badge ${u.role === 'admin' ? 'badge-error' : u.role === 'moderator' ? 'badge-processing' : 'badge-ready'}`}>
|
||||
{u.role}
|
||||
</span>
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={e => updateRole(u.id, e.target.value)}
|
||||
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem' }}
|
||||
>
|
||||
<option value="user">user</option>
|
||||
<option value="moderator">moderator</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
frontend/src/pages/DashboardPage.jsx
Normal file
96
frontend/src/pages/DashboardPage.jsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [documents, setDocuments] = useState([])
|
||||
const [stats, setStats] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.get('/documents/'),
|
||||
api.get('/attempts/stats/dashboard'),
|
||||
]).then(([docsRes, statsRes]) => {
|
||||
setDocuments(docsRes.data)
|
||||
setStats(statsRes.data)
|
||||
}).catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
{stats && (
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total_documents}</div>
|
||||
<div className="stat-label">Documents</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total_quizzes}</div>
|
||||
<div className="stat-label">Quizzes</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total_attempts}</div>
|
||||
<div className="stat-label">Attempts</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.average_score}%</div>
|
||||
<div className="stat-label">Avg Score</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h2>Your Documents</h2>
|
||||
<Link to="/upload" className="btn btn-primary">Upload PDF</Link>
|
||||
</div>
|
||||
|
||||
{documents.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No documents yet. Upload a PDF to get started!</p>
|
||||
</div>
|
||||
) : (
|
||||
documents.map(doc => (
|
||||
<Link to={`/documents/${doc.id}`} key={doc.id} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<div className="section-item">
|
||||
<div>
|
||||
<strong>{doc.original_filename}</strong>
|
||||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
|
||||
{doc.total_pages ? `${doc.total_pages} pages` : 'Processing...'} | {new Date(doc.uploaded_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`badge badge-${doc.status}`}>{doc.status}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats && stats.quiz_stats.length > 0 && (
|
||||
<div className="card">
|
||||
<h2>Quiz Performance</h2>
|
||||
{stats.quiz_stats.map(qs => (
|
||||
<div className="section-item" key={qs.quiz_id}>
|
||||
<div>
|
||||
<strong>{qs.quiz_title}</strong>
|
||||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
|
||||
{qs.attempts_count} attempts | Best: {qs.best_score}%
|
||||
</div>
|
||||
</div>
|
||||
<span style={{
|
||||
fontWeight: 700,
|
||||
color: qs.latest_score >= 70 ? '#22c55e' : qs.latest_score >= 50 ? '#f59e0b' : '#ef4444'
|
||||
}}>
|
||||
{qs.latest_score}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
221
frontend/src/pages/DocumentDetailPage.jsx
Normal file
221
frontend/src/pages/DocumentDetailPage.jsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
|
||||
export default function DocumentDetailPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const [doc, setDoc] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sectionForm, setSectionForm] = useState({ name: '', start_page: 1, end_page: 10 })
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [generating, setGenerating] = useState(null)
|
||||
const [quizTitle, setQuizTitle] = useState('')
|
||||
const [quizMode, setQuizMode] = useState('timed')
|
||||
const [timeLimitMinutes, setTimeLimitMinutes] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
|
||||
const fetchDoc = () => {
|
||||
api.get(`/documents/${id}`).then(res => {
|
||||
setDoc(res.data)
|
||||
setLoading(false)
|
||||
}).catch(() => navigate('/'))
|
||||
}
|
||||
|
||||
useEffect(() => { fetchDoc() }, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!doc || doc.status !== 'processing') return
|
||||
const interval = setInterval(() => {
|
||||
api.get(`/documents/${id}/status`).then(res => {
|
||||
if (res.data.status !== 'processing') {
|
||||
fetchDoc()
|
||||
clearInterval(interval)
|
||||
}
|
||||
})
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [doc?.status])
|
||||
|
||||
const createSection = async (e) => {
|
||||
e.preventDefault()
|
||||
setCreating(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.post(`/documents/${id}/sections`, sectionForm)
|
||||
fetchDoc()
|
||||
setSectionForm({ name: '', start_page: 1, end_page: 10 })
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to create section')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const generateQuiz = async (sectionId, sectionName) => {
|
||||
setGenerating(sectionId)
|
||||
setError('')
|
||||
try {
|
||||
const title = quizTitle || `Quiz: ${sectionName}`
|
||||
const res = await api.post('/quizzes/', {
|
||||
section_id: sectionId,
|
||||
title,
|
||||
mode: quizMode,
|
||||
time_limit_minutes: quizMode === 'timed' && timeLimitMinutes ? parseInt(timeLimitMinutes) : null,
|
||||
})
|
||||
navigate(`/quizzes/${res.data.id}`)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to generate quiz. Check AI model config.')
|
||||
setGenerating(null)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteDoc = async () => {
|
||||
if (!confirm('Delete this document and all its quizzes?')) return
|
||||
await api.delete(`/documents/${id}`)
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
|
||||
if (!doc) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2>{doc.original_filename}</h2>
|
||||
<p style={{ color: '#64748b', fontSize: '0.9rem' }}>
|
||||
{doc.total_pages ? `${doc.total_pages} pages` : ''} · Uploaded {new Date(doc.uploaded_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span className={`badge badge-${doc.status}`}>{doc.status}</span>
|
||||
{isModerator && <button className="btn btn-danger btn-sm" onClick={deleteDoc}>Delete</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{doc.status === 'processing' && (
|
||||
<div style={{ marginTop: 16, textAlign: 'center', color: '#64748b' }}>
|
||||
<div className="spinner"></div>
|
||||
<p style={{ marginTop: 8 }}>Processing PDF and extracting text... This may take a while for large files.</p>
|
||||
</div>
|
||||
)}
|
||||
{doc.status === 'error' && (
|
||||
<div className="alert alert-error" style={{ marginTop: 16 }}>
|
||||
Error: {doc.error_message || 'Unknown error'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{doc.status === 'ready' && (
|
||||
<>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{/* Moderators can create sections */}
|
||||
{isModerator && (
|
||||
<div className="card">
|
||||
<h2>Create Section</h2>
|
||||
<p style={{ color: '#64748b', marginBottom: 16, fontSize: '0.9rem' }}>
|
||||
Define page ranges — the AI will extract all questions from those pages.
|
||||
</p>
|
||||
<form onSubmit={createSection}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Section Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sectionForm.name}
|
||||
onChange={e => setSectionForm({ ...sectionForm, name: e.target.value })}
|
||||
placeholder="e.g., Chapter 1 (pages 1–50)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Start Page</label>
|
||||
<input type="number" min={1} max={doc.total_pages || 9999} value={sectionForm.start_page}
|
||||
onChange={e => setSectionForm({ ...sectionForm, start_page: parseInt(e.target.value) || 1 })} />
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>End Page</label>
|
||||
<input type="number" min={1} max={doc.total_pages || 9999} value={sectionForm.end_page}
|
||||
onChange={e => setSectionForm({ ...sectionForm, end_page: parseInt(e.target.value) || 10 })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled={creating}>
|
||||
{creating ? 'Creating...' : 'Create Section'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sections list */}
|
||||
{doc.sections && doc.sections.length > 0 && (
|
||||
<div className="card">
|
||||
<h2>Sections</h2>
|
||||
|
||||
{/* Quiz settings (moderator only) */}
|
||||
{isModerator && (
|
||||
<div style={{ background: '#f8fafc', padding: 16, borderRadius: 8, marginBottom: 16 }}>
|
||||
<strong style={{ display: 'block', marginBottom: 12 }}>Quiz Settings</strong>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Quiz Title (optional)</label>
|
||||
<input type="text" value={quizTitle} onChange={e => setQuizTitle(e.target.value)}
|
||||
placeholder="Auto-generated if empty" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Mode</label>
|
||||
<select value={quizMode} onChange={e => setQuizMode(e.target.value)}>
|
||||
<option value="timed">Timed — answers hidden, timer counts down</option>
|
||||
<option value="learning">Learning — answers + explanations shown inline</option>
|
||||
</select>
|
||||
</div>
|
||||
{quizMode === 'timed' && (
|
||||
<div className="form-group">
|
||||
<label>Time Limit (minutes, optional)</label>
|
||||
<input type="number" min={1} value={timeLimitMinutes}
|
||||
onChange={e => setTimeLimitMinutes(e.target.value)}
|
||||
placeholder="Leave blank for no limit" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{doc.sections.map(section => (
|
||||
<div className="section-item" key={section.id}>
|
||||
<div>
|
||||
<strong>{section.name}</strong>
|
||||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
|
||||
Pages {section.start_page}–{section.end_page}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{isModerator && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => generateQuiz(section.id, section.name)}
|
||||
disabled={generating === section.id}
|
||||
>
|
||||
{generating === section.id ? (
|
||||
<><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</>
|
||||
) : 'Extract & Create Quiz'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
frontend/src/pages/LoginPage.jsx
Normal file
51
frontend/src/pages/LoginPage.jsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(email, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Login failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1>Sign In</h1>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="auth-link">
|
||||
Don't have an account? <Link to="/register">Sign up</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
299
frontend/src/pages/QuizPage.jsx
Normal file
299
frontend/src/pages/QuizPage.jsx
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
|
||||
function TTSButton({ text }) {
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const audioRef = useRef(null)
|
||||
|
||||
const speak = async () => {
|
||||
if (playing) {
|
||||
audioRef.current?.pause()
|
||||
setPlaying(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
setPlaying(true)
|
||||
const res = await api.post('/tts/speak', { text }, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const audio = new Audio(url)
|
||||
audioRef.current = audio
|
||||
audio.onended = () => { setPlaying(false); URL.revokeObjectURL(url) }
|
||||
audio.onerror = () => setPlaying(false)
|
||||
audio.play()
|
||||
} catch {
|
||||
setPlaying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={speak}
|
||||
title={playing ? 'Stop' : 'Read aloud'}
|
||||
style={{
|
||||
background: playing ? '#ef4444' : '#e0e7ff',
|
||||
color: playing ? 'white' : '#3730a3',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{playing ? '⏹ Stop' : '🔊 Listen'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function TimerDisplay({ seconds, total }) {
|
||||
const pct = total > 0 ? (seconds / total) * 100 : 100
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
const color = pct > 50 ? '#22c55e' : pct > 20 ? '#f59e0b' : '#ef4444'
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: '1.1rem', color }}>
|
||||
{String(mins).padStart(2, '0')}:{String(secs).padStart(2, '0')}
|
||||
</div>
|
||||
<div style={{ width: 100, background: '#e2e8f0', borderRadius: 999, height: 6 }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 999, transition: 'width 1s' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function QuizPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [quiz, setQuiz] = useState(null)
|
||||
const [answers, setAnswers] = useState({})
|
||||
const [currentIdx, setCurrentIdx] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [attemptId, setAttemptId] = useState(null)
|
||||
const [timeLeft, setTimeLeft] = useState(null)
|
||||
const [totalTime, setTotalTime] = useState(null)
|
||||
const timerRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const quizRes = await api.get(`/quizzes/${id}`)
|
||||
setQuiz(quizRes.data)
|
||||
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
|
||||
setAttemptId(attemptRes.data.id)
|
||||
|
||||
if (quizRes.data.mode === 'timed' && quizRes.data.time_limit_minutes) {
|
||||
const secs = quizRes.data.time_limit_minutes * 60
|
||||
setTimeLeft(secs)
|
||||
setTotalTime(secs)
|
||||
}
|
||||
} catch {
|
||||
navigate('/')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => clearInterval(timerRef.current)
|
||||
}, [id])
|
||||
|
||||
// Timer countdown
|
||||
useEffect(() => {
|
||||
if (timeLeft === null) return
|
||||
if (timeLeft <= 0) {
|
||||
handleSubmit(true)
|
||||
return
|
||||
}
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeLeft(t => {
|
||||
if (t <= 1) { clearInterval(timerRef.current); return 0 }
|
||||
return t - 1
|
||||
})
|
||||
}, 1000)
|
||||
return () => clearInterval(timerRef.current)
|
||||
}, [timeLeft === null])
|
||||
|
||||
const setAnswer = (questionId, value) => {
|
||||
setAnswers(prev => ({ ...prev, [questionId]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = useCallback(async (autoSubmit = false) => {
|
||||
if (!attemptId || submitting) return
|
||||
clearInterval(timerRef.current)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const submission = {
|
||||
answers: Object.entries(answers).map(([qid, answer]) => ({
|
||||
question_id: parseInt(qid),
|
||||
user_answer: answer,
|
||||
})),
|
||||
}
|
||||
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
|
||||
navigate(`/results/${attemptId}`, { state: { result: res.data } })
|
||||
} catch (err) {
|
||||
if (!autoSubmit) alert(err.response?.data?.detail || 'Submission failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [attemptId, answers, submitting])
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
|
||||
if (!quiz) return null
|
||||
|
||||
const questions = quiz.questions || []
|
||||
const current = questions[currentIdx]
|
||||
const isLearning = quiz.mode === 'learning'
|
||||
const answeredCount = Object.keys(answers).length
|
||||
const totalCount = questions.length
|
||||
const isLast = currentIdx === totalCount - 1
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="card" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 2 }}>{quiz.title}</h2>
|
||||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
|
||||
<span style={{
|
||||
background: isLearning ? '#d1fae5' : '#e0e7ff',
|
||||
color: isLearning ? '#065f46' : '#3730a3',
|
||||
padding: '2px 8px', borderRadius: 12, fontWeight: 600, marginRight: 8
|
||||
}}>
|
||||
{isLearning ? 'Learning Mode' : 'Timed Mode'}
|
||||
</span>
|
||||
Question {currentIdx + 1} of {totalCount} · {answeredCount} answered
|
||||
</div>
|
||||
</div>
|
||||
{timeLeft !== null && (
|
||||
<TimerDisplay seconds={timeLeft} total={totalTime} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="progress-bar" style={{ marginBottom: 20 }}>
|
||||
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
|
||||
</div>
|
||||
|
||||
{/* Current question */}
|
||||
{current && (
|
||||
<div className="question-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<h3 style={{ flex: 1 }}>
|
||||
Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}
|
||||
</h3>
|
||||
<TTSButton text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`} />
|
||||
</div>
|
||||
|
||||
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '8px 0', display: 'inline-block' }}>
|
||||
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
|
||||
</span>
|
||||
|
||||
{/* Question image */}
|
||||
{current.image_path && (
|
||||
<div style={{ margin: '12px 0' }}>
|
||||
<img
|
||||
src={`/uploads/${current.image_path}`}
|
||||
alt="Question illustration"
|
||||
style={{ maxWidth: '100%', maxHeight: 300, borderRadius: 8, border: '1px solid #e2e8f0' }}
|
||||
onError={e => e.target.style.display = 'none'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Answer options */}
|
||||
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
|
||||
<div className="options" style={{ marginTop: 12 }}>
|
||||
{current.options.map((opt, i) => {
|
||||
const isSelected = answers[current.id] === opt
|
||||
const isCorrect = isLearning && opt === current.correct_answer
|
||||
const isWrong = isLearning && isSelected && opt !== current.correct_answer
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`option ${isSelected ? 'selected' : ''} ${isCorrect && isSelected ? 'correct' : ''} ${isWrong ? 'incorrect' : ''}`}
|
||||
onClick={() => setAnswer(current.id, opt)}
|
||||
>
|
||||
<input type="radio" name={`q-${current.id}`} checked={isSelected} onChange={() => setAnswer(current.id, opt)} />
|
||||
<span>{opt}</span>
|
||||
{isLearning && isCorrect && <span style={{ marginLeft: 'auto', color: '#22c55e', fontWeight: 600 }}>✓ Correct</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type your answer..."
|
||||
value={answers[current.id] || ''}
|
||||
onChange={e => setAnswer(current.id, e.target.value)}
|
||||
style={{ marginTop: 12, width: '100%', padding: '10px 14px', border: '1px solid #d1d5db', borderRadius: 8 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Learning mode: show explanation after answering */}
|
||||
{isLearning && answers[current.id] && current.explanation && (
|
||||
<div className="explanation" style={{ marginTop: 16 }}>
|
||||
<strong>Explanation:</strong> {current.explanation}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Learning mode: show correct answer for fill blank */}
|
||||
{isLearning && current.question_type === 'fill_blank' && answers[current.id] && (
|
||||
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
|
||||
<strong>Answer:</strong> {current.correct_answer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16 }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setCurrentIdx(i => Math.max(0, i - 1))}
|
||||
disabled={currentIdx === 0}
|
||||
>← Previous</button>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
{questions.map((q, i) => (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => setCurrentIdx(i)}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: '50%', border: 'none', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600,
|
||||
background: i === currentIdx ? '#2563eb' : answers[q.id] ? '#d1fae5' : '#e2e8f0',
|
||||
color: i === currentIdx ? 'white' : answers[q.id] ? '#065f46' : '#475569',
|
||||
}}
|
||||
>{i + 1}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLast ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handleSubmit(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Quiz'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setCurrentIdx(i => Math.min(totalCount - 1, i + 1))}
|
||||
>Next →</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{answeredCount > 0 && !isLast && (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<button className="btn btn-secondary" onClick={() => handleSubmit(false)} disabled={submitting}>
|
||||
Submit now ({answeredCount}/{totalCount} answered)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
frontend/src/pages/QuizzesPage.jsx
Normal file
43
frontend/src/pages/QuizzesPage.jsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
|
||||
export default function QuizzesPage() {
|
||||
const [quizzes, setQuizzes] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/quizzes/').then(res => setQuizzes(res.data))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<h2>Your Quizzes</h2>
|
||||
{quizzes.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No quizzes yet. Upload a PDF and generate quizzes from sections.</p>
|
||||
</div>
|
||||
) : (
|
||||
quizzes.map(quiz => (
|
||||
<div className="section-item" key={quiz.id}>
|
||||
<div>
|
||||
<strong>{quiz.title}</strong>
|
||||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
|
||||
{quiz.questions_count} questions | {new Date(quiz.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Link to={`/quizzes/${quiz.id}`} className="btn btn-primary btn-sm">Take Quiz</Link>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
frontend/src/pages/RegisterPage.jsx
Normal file
56
frontend/src/pages/RegisterPage.jsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await register(email, password, name)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Registration failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1>Create Account</h1>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={6} />
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Sign Up'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="auth-link">
|
||||
Already have an account? <Link to="/login">Sign in</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
frontend/src/pages/ResultsPage.jsx
Normal file
75
frontend/src/pages/ResultsPage.jsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
|
||||
export default function ResultsPage() {
|
||||
const { id } = useParams()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [result, setResult] = useState(location.state?.result || null)
|
||||
const [loading, setLoading] = useState(!result)
|
||||
|
||||
useEffect(() => {
|
||||
if (!result) {
|
||||
api.get(`/attempts/${id}`)
|
||||
.then(res => setResult(res.data))
|
||||
.catch(() => navigate('/'))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}, [id])
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading results...</div>
|
||||
if (!result) return null
|
||||
|
||||
const scoreClass = result.percentage >= 70 ? 'good' : result.percentage >= 50 ? 'ok' : 'poor'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<div className="score-display">
|
||||
<div className={`score-value ${scoreClass}`}>{result.percentage}%</div>
|
||||
<div style={{ fontSize: '1.1rem', color: '#64748b', marginTop: 8 }}>
|
||||
{result.score} out of {result.total_questions} correct
|
||||
</div>
|
||||
<div style={{ marginTop: 16, color: '#475569' }}>
|
||||
{result.percentage >= 90 ? 'Excellent! You\'ve mastered this material.' :
|
||||
result.percentage >= 70 ? 'Good job! A few more reviews and you\'ll nail it.' :
|
||||
result.percentage >= 50 ? 'Getting there! Keep practicing.' :
|
||||
'Don\'t worry — review the explanations below and try again!'}
|
||||
</div>
|
||||
<div style={{ marginTop: 20, display: 'flex', gap: 12, justifyContent: 'center' }}>
|
||||
<Link to={`/quizzes/${result.quiz_id}`} className="btn btn-primary">Retake Quiz</Link>
|
||||
<Link to="/" className="btn btn-secondary">Dashboard</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style={{ marginBottom: 16 }}>Question Review</h2>
|
||||
{result.answers?.map((ans, idx) => (
|
||||
<div className="question-card" key={idx} style={{
|
||||
borderLeftColor: ans.is_correct ? '#22c55e' : '#ef4444'
|
||||
}}>
|
||||
<h3>Q{idx + 1}. {ans.question_text}</h3>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600, color: ans.is_correct ? '#22c55e' : '#ef4444' }}>
|
||||
{ans.is_correct ? 'Correct' : 'Incorrect'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem' }}>
|
||||
<div><strong>Your answer:</strong> {ans.user_answer || '(no answer)'}</div>
|
||||
{!ans.is_correct && (
|
||||
<div style={{ color: '#22c55e' }}><strong>Correct answer:</strong> {ans.correct_answer}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{ans.explanation && (
|
||||
<div className="explanation">
|
||||
<strong>Explanation:</strong> {ans.explanation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
frontend/src/pages/UploadPage.jsx
Normal file
114
frontend/src/pages/UploadPage.jsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
|
||||
export default function UploadPage() {
|
||||
const [file, setFile] = useState(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const fileRef = useRef()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleFile = (f) => {
|
||||
if (f && f.type === 'application/pdf') {
|
||||
setFile(f)
|
||||
setError('')
|
||||
} else {
|
||||
setError('Please select a PDF file')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setError('')
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await api.post('/documents/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => {
|
||||
if (e.total) setProgress(Math.round((e.loaded / e.total) * 100))
|
||||
},
|
||||
})
|
||||
navigate(`/documents/${res.data.id}`)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Upload failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<h2>Upload PDF Document</h2>
|
||||
<p style={{ color: '#64748b', marginBottom: 20 }}>
|
||||
Upload a PDF file (up to 500MB) to generate interactive quizzes from its content.
|
||||
</p>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div
|
||||
className={`upload-area ${dragging ? 'dragging' : ''}`}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
handleFile(e.dataTransfer.files[0])
|
||||
}}
|
||||
>
|
||||
{file ? (
|
||||
<div>
|
||||
<div style={{ fontSize: '1.1rem', fontWeight: 600 }}>{file.name}</div>
|
||||
<div style={{ color: '#64748b', marginTop: 4 }}>
|
||||
{(file.size / 1024 / 1024).toFixed(1)} MB
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ fontSize: '2rem', marginBottom: 8 }}>PDF</div>
|
||||
<div>Click or drag a PDF file here</div>
|
||||
<div style={{ color: '#94a3b8', fontSize: '0.85rem', marginTop: 4 }}>
|
||||
Supports files up to 500MB
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
hidden
|
||||
onChange={(e) => handleFile(e.target.files[0])}
|
||||
/>
|
||||
|
||||
{uploading && (
|
||||
<div className="progress-bar">
|
||||
<div className="fill" style={{ width: `${progress}%` }}></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 20, display: 'flex', gap: 12 }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploading}
|
||||
>
|
||||
{uploading ? `Uploading ${progress}%...` : 'Upload & Process'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => navigate('/')}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
frontend/vite.config.js
Normal file
11
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000'
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue