diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..7dec730fdf8c53e8d507a1f67ee65f12254c1fee --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +venv/ +__pycache__/ +.env +*.pyc +*.db +*.sqlite3 +*.zip +uploads/ +vector_store_data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..a0cfd5929147937bfd0f6e512bbc8b9e35f89114 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Use official Python image +FROM python:3.9 + +# Create a non-root user for Hugging Face security +RUN useradd -m -u 1000 user +USER user +ENV HOME=/home/user \ + PATH=/home/user/.local/bin:$PATH + +WORKDIR $HOME/app + +# Copy your backend files into the container +COPY --chown=user . . + +# Install the dependencies +RUN pip install --no-cache-dir --upgrade -r requirements.txt + +# Hugging Face Spaces uses port 7860 by default +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c479c5a52a8287ce3d110d54a13583d97bf1e234 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# 1. Create virtual env +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# 2. Install deps +pip install -r requirements.txt + +# 3. Create .env from example and fill in your API keys +cp .env.example .env + +# 4. Run the server +uvicorn main:app --reload --host 0.0.0.0 --port 8000 + +# 5. Open API docs +# http://localhost:8000/docs + +# 6. Create admin user +```python -c " +from app.database import SessionLocal, engine, Base +from app.models.user import User +from app.utils.security import hash_password +Base.metadata.create_all(bind=engine) +db = SessionLocal() +if not db.query(User).filter(User.role == 'admin').first(): + db.add(User( + email='admin@examinal.com', + username='admin', + hashed_password=hash_password('admin123'), + full_name='System Admin', + role='admin', + )) + db.commit() + print('Admin created: admin / admin123') +else: + print('Admin already exists') +db.close() +``` + + +## API Endpoint Summary + +| Method | Endpoint | Role | Purpose | +| --- | --- | --- | --- | +| POST | /api/auth/register | Public | Register | +| POST | /api/auth/login | Public | Login → JWT | +| POST | /api/auth/refresh | Any | Refresh token | +| GET | /api/auth/me | Any | Current user | +| GET/PATCH/DELETE | /api/users/{id} | Admin/self | User CRUD | +| POST/GET/PATCH/DELETE | /api/courses/... | Instructor+ | Course CRUD | +| POST | /api/courses/{id}/enroll | Instructor+ | Enroll student | +| POST | /api/content/upload/{course_id} | Instructor+ | Upload file | +| POST | /api/content/ingest/{doc_id} | Instructor+ | Parse & embed | +| POST | /api/questions/generate | Instructor+ | RAG question gen | +| CRUD | /api/questions/... | Instructor+ | Manual question CRUD | +| CRUD | /api/exams/... | Instructor+ | Exam lifecycle | +| POST | /api/exams/{id}/publish | Instructor+ | Publish exam | +| POST | /api/exams/{id}/assign | Instructor+ | Assign students | +| POST | /api/submissions/start | Student | Begin exam | +| POST | /api/submissions/{id}/autosave | Student | Save progress | +| POST | /api/submissions/{id}/submit | Student | Final submit | +| POST | /api/submissions/activity | Student | Log proctoring event | +| POST | /api/grading/auto/{sub_id} | Instructor+ | Auto‑grade | +| POST | /api/grading/auto/exam/{id} | Instructor+ | Batch auto‑grade | +| PATCH | /api/grading/manual/{ans_id} | Instructor+ | Override score | +| GET | /api/analytics/exam/{id} | Instructor+ | Exam stats | +| GET | /api/analytics/student/{id} | Instructor+/self | Performance | +| GET | /api/analytics/course/{id} | Instructor+ | Course overview | +| GET | /api/admin/stats | Admin | Platform stats | +| GET | /api/admin/activity-logs | Admin | Audit trail | +| GET | /api/admin/exam/{id}/integrity | Admin | Cheating flags | diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000000000000000000000000000000000000..8b0353b1bc5a6e242d9fc7e4ca86922de0e4f534 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = sqlite:///./examinal.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[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 \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000000000000000000000000000000000000..5b8b0c2f9bfad3995a1b533090d713786480f1a2 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,43 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool +from alembic import context + +from app.database import Base +from app.config import settings + +# ── Import models so metadata is populated ── +from app.models import user, course, content, exam, question, submission, activity_log # noqa + +config = context.config +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + 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() \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000000000000000000000000000000000000..038e8a67c8c83adf0a5e700d6eb074fb4285a6f4 --- /dev/null +++ b/app/config.py @@ -0,0 +1,75 @@ +""" +Central settings — Full NVIDIA stack configuration. +""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8", extra="ignore" + ) + + APP_NAME: str = "Examinal" + APP_VERSION: str = "2.0.0" + DEBUG: bool = False + + SECRET_KEY: str = "CHANGE-ME" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + DATABASE_URL: str = "sqlite:///./examinal.db" + + # ── NVIDIA NIM ── + NVIDIA_API_KEY: str = "" + NVIDIA_BASE_URL: str = "https://integrate.api.nvidia.com/v1" + + # ── LLM ── + LLM_PROVIDER: str = "nvidia" + NVIDIA_LLM_MODEL: str = "nvidia/nemotron-3-nano-30b-a3b" + LLM_TEMPERATURE: float = 0.4 + LLM_MAX_TOKENS: int = 8192 + + # ── Fallback LLM ── + FALLBACK_LLM_PROVIDER: str = "openai" + OPENAI_API_KEY: str = "" + OPENAI_MODEL: str = "gpt-4o-mini" + GOOGLE_API_KEY: str = "" + GEMINI_MODEL: str = "gemini-1.5-flash" + + # ── Embedding ── + EMBEDDING_PROVIDER: str = "nvidia_api" + NVIDIA_EMBED_MODEL: str = "nvidia/llama-3.2-nv-embedqa-1b-v2" + EMBEDDING_MODEL: str = "all-MiniLM-L6-v2" + + # ── Reranking ── + USE_RERANKER: bool = True + NVIDIA_RERANK_MODEL: str = "nvidia/llama-nemotron-rerank-1b-v2" + + # ── Retrieval ── + RETRIEVAL_TOP_K: int = 25 + RERANK_TOP_K: int = 6 + CHUNK_SIZE: int = 600 + CHUNK_OVERLAP: int = 150 + + # ── Grading ── + GRADING_MODE: str = "multi_pass" + GRADING_CONFIDENCE_THRESHOLD: float = 0.7 + ENABLE_RUBRIC_GRADING: bool = True + + MAIL_USERNAME: str = "aryanmirza112233@gmail.com" + MAIL_PASSWORD: str = "" + MAIL_FROM: str = "aryanmirza112233@gmail.com" + MAIL_PORT: int = 587 + MAIL_SERVER: str = "smtp.gmail.com" + MAIL_USE_TLS: bool = True + MAIL_USE_SSL: bool = False + + # ── Paths ── + UPLOAD_DIR: str = "uploads" + VECTOR_STORE_DIR: str = "vector_store_data" + MAX_UPLOAD_SIZE_MB: int = 50 + + +settings = Settings() \ No newline at end of file diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000000000000000000000000000000000000..7eb122ff0d4a529af0bd8bc939b32be1b2532b19 --- /dev/null +++ b/app/database.py @@ -0,0 +1,33 @@ +""" +SQLAlchemy engine & session factory. +""" + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase + +from app.config import settings + +connect_args = {} +if settings.DATABASE_URL.startswith("sqlite"): + connect_args = {"check_same_thread": False} + +engine = create_engine( + settings.DATABASE_URL, + connect_args=connect_args, + echo=settings.DEBUG, + pool_pre_ping=True, +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..d6783128094958dad49ee8427e0af9c199232ad4 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,54 @@ +""" +Shared FastAPI dependencies: current‑user injection, role checks. +""" + +from typing import Annotated, List + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from sqlalchemy.orm import Session + +from app.config import settings +from app.database import get_db +from app.models.user import User + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + + +def get_current_user( + token: Annotated[str, Depends(oauth2_scheme)], + db: Session = Depends(get_db), +) -> User: + credentials_exc = 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]) + user_id: int | None = payload.get("sub") + if user_id is None: + raise credentials_exc + except JWTError: + raise credentials_exc + + user = db.query(User).filter(User.id == int(user_id)).first() + if user is None or not user.is_active: + raise credentials_exc + return user + + +def require_roles(allowed: List[str]): + """Return a dependency that ensures the user has one of the allowed roles.""" + def _check(current_user: User = Depends(get_current_user)) -> User: + if current_user.role not in allowed: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + return current_user + return _check + + +CurrentUser = Annotated[User, Depends(get_current_user)] +AdminUser = Annotated[User, Depends(require_roles(["admin"]))] +InstructorUser = Annotated[User, Depends(require_roles(["admin", "instructor"]))] +StudentUser = Annotated[User, Depends(require_roles(["student"]))] \ No newline at end of file diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/middleware/activity_logger.py b/app/middleware/activity_logger.py new file mode 100644 index 0000000000000000000000000000000000000000..7345cd425ca37e58e8a440adf3074b3ce0837891 --- /dev/null +++ b/app/middleware/activity_logger.py @@ -0,0 +1,30 @@ +""" +Middleware that logs every request for audit purposes. +""" + +import time +import logging + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +logger = logging.getLogger("examinal.access") + + +class ActivityLoggerMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next) -> Response: + start = time.perf_counter() + response: Response = await call_next(request) + elapsed = time.perf_counter() - start + + logger.info( + "%s %s %s %.3fs %s", + request.client.host if request.client else "-", + request.method, + request.url.path, + elapsed, + response.status_code, + ) + response.headers["X-Process-Time"] = f"{elapsed:.4f}" + return response \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fe56cc8d3e5cc1861983e2a1179b42bacccb06a4 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,23 @@ +from app.models.user import User +from app.models.course import Course, CourseEnrollment +from app.models.content import ContentDocument, ContentPassage +from app.models.exam import Exam, ExamAssignment +from app.models.question import ExamQuestion +from app.models.submission import ExamSubmission, AnswerResponse +from app.models.activity_log import ActivityLog +from app.models.contact import ContactMessage + +__all__ = [ + "User", + "Course", + "CourseEnrollment", + "ContentDocument", + "ContentPassage", + "Exam", + "ExamAssignment", + "ExamQuestion", + "ExamSubmission", + "AnswerResponse", + "ActivityLog", + "ContactMessage", +] \ No newline at end of file diff --git a/app/models/activity_log.py b/app/models/activity_log.py new file mode 100644 index 0000000000000000000000000000000000000000..cbfb70e7d896c133c69ee38fd73cc89f2f42f19f --- /dev/null +++ b/app/models/activity_log.py @@ -0,0 +1,22 @@ +from datetime import datetime, timezone + +from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class ActivityLog(Base): + __tablename__ = "activity_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True) + exam_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + submission_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + action_type: Mapped[str] = mapped_column(String(100), nullable=False) + details: Mapped[dict | None] = mapped_column(JSON, nullable=True) + ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + + user = relationship("User", back_populates="activity_logs") \ No newline at end of file diff --git a/app/models/contact.py b/app/models/contact.py new file mode 100644 index 0000000000000000000000000000000000000000..bfe466882e00cd205cb0a3c7d4d2fd435e1387f8 --- /dev/null +++ b/app/models/contact.py @@ -0,0 +1,27 @@ +from sqlalchemy.orm import relationship +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey +from datetime import datetime +from app.database import Base + +class ContactMessage(Base): + __tablename__ = "contact_messages" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + email = Column(String(255), nullable=False) + subject = Column(String(1000), nullable=True) + message = Column(Text, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + # Many replies + replies = relationship("ContactReply", back_populates="message") + +class ContactReply(Base): + __tablename__ = "contact_replies" + + id = Column(Integer, primary_key=True, index=True) + message_id = Column(Integer, ForeignKey("contact_messages.id"), nullable=False) + content = Column(Text, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + message = relationship("ContactMessage", back_populates="replies") diff --git a/app/models/content.py b/app/models/content.py new file mode 100644 index 0000000000000000000000000000000000000000..fad3c68d8f1f60d5a722a007d29420854a9f9830 --- /dev/null +++ b/app/models/content.py @@ -0,0 +1,37 @@ +from datetime import datetime, timezone + +from sqlalchemy import String, Integer, Text, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class ContentDocument(Base): + __tablename__ = "content_documents" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + course_id: Mapped[int] = mapped_column(Integer, ForeignKey("courses.id"), nullable=False) + filename: Mapped[str] = mapped_column(String(500), nullable=False) + original_filename: Mapped[str] = mapped_column(String(500), nullable=False) + file_type: Mapped[str] = mapped_column(String(20), nullable=False) # pdf | docx | pptx + file_size: Mapped[int] = mapped_column(Integer, nullable=False) + upload_status: Mapped[str] = mapped_column(String(30), default="uploaded") # uploaded | processing | indexed | failed + uploaded_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + + course = relationship("Course", back_populates="documents") + passages = relationship("ContentPassage", back_populates="document", cascade="all, delete-orphan") + + +class ContentPassage(Base): + __tablename__ = "content_passages" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + document_id: Mapped[int] = mapped_column(Integer, ForeignKey("content_documents.id"), nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False) + page_number: Mapped[int | None] = mapped_column(Integer, nullable=True) + chunk_index: Mapped[int] = mapped_column(Integer, nullable=False) + embedding_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + + document = relationship("ContentDocument", back_populates="passages") \ No newline at end of file diff --git a/app/models/course.py b/app/models/course.py new file mode 100644 index 0000000000000000000000000000000000000000..18e570868f94b6a7f7953341f0cccadea77a493a --- /dev/null +++ b/app/models/course.py @@ -0,0 +1,39 @@ +from datetime import datetime, timezone + +from sqlalchemy import String, Integer, Text, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Course(Base): + __tablename__ = "courses" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + code: Mapped[str] = mapped_column(String(30), unique=True, nullable=False) + instructor_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + instructor = relationship("User", back_populates="courses_created") + enrollments = relationship("CourseEnrollment", back_populates="course", cascade="all, delete-orphan") + documents = relationship("ContentDocument", back_populates="course", cascade="all, delete-orphan") + exams = relationship("Exam", back_populates="course", cascade="all, delete-orphan") + + +class CourseEnrollment(Base): + __tablename__ = "course_enrollments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + course_id: Mapped[int] = mapped_column(Integer, ForeignKey("courses.id"), nullable=False) + student_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + enrolled_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + + course = relationship("Course", back_populates="enrollments") + student = relationship("User", back_populates="enrollments") \ No newline at end of file diff --git a/app/models/exam.py b/app/models/exam.py new file mode 100644 index 0000000000000000000000000000000000000000..9c078a5faf1bba0ef47be2d78309b62bddd43451 --- /dev/null +++ b/app/models/exam.py @@ -0,0 +1,47 @@ +from datetime import datetime, timezone + +from sqlalchemy import String, Integer, Float, Boolean, Text, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Exam(Base): + __tablename__ = "exams" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + course_id: Mapped[int] = mapped_column(Integer, ForeignKey("courses.id"), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + duration_minutes: Mapped[int] = mapped_column(Integer, default=60) + total_marks: Mapped[float] = mapped_column(Float, default=100.0) + passing_marks: Mapped[float] = mapped_column(Float, default=40.0) + start_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + end_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + is_published: Mapped[bool] = mapped_column(Boolean, default=False) + shuffle_questions: Mapped[bool] = mapped_column(Boolean, default=False) + show_results: Mapped[bool] = mapped_column(Boolean, default=True) + max_attempts: Mapped[int] = mapped_column(Integer, default=1) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + course = relationship("Course", back_populates="exams") + questions = relationship("ExamQuestion", back_populates="exam", cascade="all, delete-orphan") + assignments = relationship("ExamAssignment", back_populates="exam", cascade="all, delete-orphan") + submissions = relationship("ExamSubmission", back_populates="exam", cascade="all, delete-orphan") + + +class ExamAssignment(Base): + __tablename__ = "exam_assignments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + exam_id: Mapped[int] = mapped_column(Integer, ForeignKey("exams.id"), nullable=False) + student_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + assigned_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + + exam = relationship("Exam", back_populates="assignments") \ No newline at end of file diff --git a/app/models/question.py b/app/models/question.py new file mode 100644 index 0000000000000000000000000000000000000000..3d4a7d73e28f5032c4bb2c5acd6b69efc313f4f3 --- /dev/null +++ b/app/models/question.py @@ -0,0 +1,26 @@ +from datetime import datetime, timezone + +from sqlalchemy import String, Integer, Float, Text, DateTime, ForeignKey, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class ExamQuestion(Base): + __tablename__ = "exam_questions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + exam_id: Mapped[int] = mapped_column(Integer, ForeignKey("exams.id"), nullable=False) + question_text: Mapped[str] = mapped_column(Text, nullable=False) + question_type: Mapped[str] = mapped_column(String(30), nullable=False) # mcq | short_answer | descriptive + options: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {"A":"...","B":"...","C":"...","D":"..."} + correct_answer: Mapped[str] = mapped_column(Text, nullable=False) + marks: Mapped[float] = mapped_column(Float, default=1.0) + explanation: Mapped[str | None] = mapped_column(Text, nullable=True) + source_passage_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("content_passages.id"), nullable=True) + difficulty: Mapped[str] = mapped_column(String(20), default="medium") # easy | medium | hard + order_index: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + + exam = relationship("Exam", back_populates="questions") + answers = relationship("AnswerResponse", back_populates="question", cascade="all, delete-orphan") \ No newline at end of file diff --git a/app/models/submission.py b/app/models/submission.py new file mode 100644 index 0000000000000000000000000000000000000000..7de2b5198d9181a1c63f108d079544daff2c3271 --- /dev/null +++ b/app/models/submission.py @@ -0,0 +1,44 @@ +from datetime import datetime, timezone + +from sqlalchemy import String, Integer, Float, Text, Boolean, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class ExamSubmission(Base): + __tablename__ = "exam_submissions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + exam_id: Mapped[int] = mapped_column(Integer, ForeignKey("exams.id"), nullable=False) + student_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + started_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + status: Mapped[str] = mapped_column(String(30), default="in_progress") # in_progress | submitted | graded + total_score: Mapped[float | None] = mapped_column(Float, nullable=True) + max_score: Mapped[float | None] = mapped_column(Float, nullable=True) + percentage: Mapped[float | None] = mapped_column(Float, nullable=True) + is_passed: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + graded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + exam = relationship("Exam", back_populates="submissions") + student = relationship("User", back_populates="submissions") + answers = relationship("AnswerResponse", back_populates="submission", cascade="all, delete-orphan") + + +class AnswerResponse(Base): + __tablename__ = "answer_responses" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + submission_id: Mapped[int] = mapped_column(Integer, ForeignKey("exam_submissions.id"), nullable=False) + question_id: Mapped[int] = mapped_column(Integer, ForeignKey("exam_questions.id"), nullable=False) + student_answer: Mapped[str | None] = mapped_column(Text, nullable=True) + is_correct: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + score: Mapped[float] = mapped_column(Float, default=0.0) + max_score: Mapped[float] = mapped_column(Float, default=1.0) + ai_feedback: Mapped[str | None] = mapped_column(Text, nullable=True) + confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + + submission = relationship("ExamSubmission", back_populates="answers") + question = relationship("ExamQuestion", back_populates="answers") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000000000000000000000000000000000000..3f38b577bf0122f75f31972295bd3e409733e44d --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,30 @@ +from datetime import datetime, timezone + +from sqlalchemy import String, Boolean, DateTime, Integer +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + username: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + full_name: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[str] = mapped_column(String(20), nullable=False, default="student") # admin | instructor | student + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + # relationships + courses_created = relationship("Course", back_populates="instructor", lazy="selectin") + enrollments = relationship("CourseEnrollment", back_populates="student", lazy="selectin") + submissions = relationship("ExamSubmission", back_populates="student", lazy="selectin") + activity_logs = relationship("ActivityLog", back_populates="user", lazy="selectin") \ No newline at end of file diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..3f98f2a0cb0f5e3c6cef7337d739d672f4a9be85 --- /dev/null +++ b/app/routers/admin.py @@ -0,0 +1,93 @@ +""" +Admin‑only endpoints: audit logs, stats, DB health. +""" + +from typing import List + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session +from sqlalchemy import func + +from app.database import get_db +from app.dependencies import AdminUser +from app.models.user import User +from app.models.course import Course +from app.models.exam import Exam +from app.models.submission import ExamSubmission +from app.models.activity_log import ActivityLog + +router = APIRouter(prefix="/api/admin", tags=["Admin"]) + + +@router.get("/stats") +def platform_stats(_admin: AdminUser, db: Session = Depends(get_db)): # type: ignore + return { + "total_users": db.query(func.count(User.id)).scalar(), + "total_instructors": db.query(func.count(User.id)).filter(User.role == "instructor").scalar(), + "total_students": db.query(func.count(User.id)).filter(User.role == "student").scalar(), + "total_courses": db.query(func.count(Course.id)).scalar(), + "total_exams": db.query(func.count(Exam.id)).scalar(), + "total_submissions": db.query(func.count(ExamSubmission.id)).scalar(), + "published_exams": db.query(func.count(Exam.id)).filter(Exam.is_published == True).scalar(), # noqa + } + + +@router.get("/activity-logs") +def get_activity_logs( + user_id: int | None = None, + exam_id: int | None = None, + action_type: str | None = None, + skip: int = 0, + limit: int = Query(default=100, le=500), + _admin: AdminUser = None, # type: ignore + db: Session = Depends(get_db), +): + q = db.query(ActivityLog).order_by(ActivityLog.created_at.desc()) + if user_id: + q = q.filter(ActivityLog.user_id == user_id) + if exam_id: + q = q.filter(ActivityLog.exam_id == exam_id) + if action_type: + q = q.filter(ActivityLog.action_type == action_type) + logs = q.offset(skip).limit(limit).all() + return [ + { + "id": l.id, + "user_id": l.user_id, + "exam_id": l.exam_id, + "submission_id": l.submission_id, + "action_type": l.action_type, + "details": l.details, + "ip_address": l.ip_address, + "created_at": l.created_at.isoformat() if l.created_at else None, + } + for l in logs + ] + + +@router.get("/exam/{exam_id}/integrity") +def exam_integrity_report(exam_id: int, _admin: AdminUser, db: Session = Depends(get_db)): # type: ignore + """Suspicious activity summary for an exam.""" + suspicious_actions = ["tab_switch", "copy_attempt", "paste_attempt", "focus_lost"] + logs = ( + db.query(ActivityLog) + .filter(ActivityLog.exam_id == exam_id, ActivityLog.action_type.in_(suspicious_actions)) + .all() + ) + # Group by user + user_flags: dict = {} + for log in logs: + uid = log.user_id + if uid not in user_flags: + user_flags[uid] = {"user_id": uid, "events": []} + user_flags[uid]["events"].append({ + "action": log.action_type, + "time": log.created_at.isoformat() if log.created_at else None, + "details": log.details, + }) + + return { + "exam_id": exam_id, + "total_flags": len(logs), + "flagged_students": list(user_flags.values()), + } \ No newline at end of file diff --git a/app/routers/analytics.py b/app/routers/analytics.py new file mode 100644 index 0000000000000000000000000000000000000000..49706209bf9a74e8de168116302e317df730f046 --- /dev/null +++ b/app/routers/analytics.py @@ -0,0 +1,50 @@ +""" +Analytics and reporting endpoints. +""" + +from typing import List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.dependencies import InstructorUser, CurrentUser +from app.schemas.analytics import ExamAnalytics, QuestionAnalytics, StudentPerformance, CourseAnalytics +from app.services.analytics_service import AnalyticsService + +router = APIRouter(prefix="/api/analytics", tags=["Analytics"]) + + +@router.get("/exam/{exam_id}", response_model=ExamAnalytics) +def exam_analytics(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore + svc = AnalyticsService(db) + result = svc.get_exam_analytics(exam_id) + if not result: + raise HTTPException(status_code=404, detail="Exam not found") + return result + + +@router.get("/exam/{exam_id}/questions", response_model=List[QuestionAnalytics]) +def question_analytics(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore + svc = AnalyticsService(db) + return svc.get_question_analytics(exam_id) + + +@router.get("/student/{student_id}", response_model=StudentPerformance) +def student_performance(student_id: int, current_user: CurrentUser, db: Session = Depends(get_db)): + if current_user.role == "student" and current_user.id != student_id: + raise HTTPException(status_code=403, detail="Access denied") + svc = AnalyticsService(db) + result = svc.get_student_performance(student_id) + if not result: + raise HTTPException(status_code=404, detail="Student not found") + return result + + +@router.get("/course/{course_id}", response_model=CourseAnalytics) +def course_analytics(course_id: int, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore + svc = AnalyticsService(db) + result = svc.get_course_analytics(course_id) + if not result: + raise HTTPException(status_code=404, detail="Course not found") + return result \ No newline at end of file diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..aa5269f8c9191b94887dcfc812dcf042cadcf190 --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,46 @@ +""" +Authentication endpoints: register, login, refresh, me. +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app.database import get_db +from app.dependencies import CurrentUser +from app.schemas.user import UserCreate, UserOut, Token +from app.services.auth_service import AuthService + +router = APIRouter(prefix="/api/auth", tags=["Authentication"]) + + +@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED) +def register(payload: UserCreate, db: Session = Depends(get_db)): + service = AuthService(db) + user = service.register(payload) + return user + + +@router.post("/login", response_model=Token) +def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + service = AuthService(db) + user = service.authenticate(form.username, form.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + ) + tokens = service.create_tokens(user) + return tokens + + +@router.post("/refresh", response_model=Token) +def refresh_token(refresh_token: str, db: Session = Depends(get_db)): + service = AuthService(db) + tokens = service.refresh(refresh_token) + return tokens + + +@router.get("/me", response_model=UserOut) +def get_me(current_user: CurrentUser): + return current_user \ No newline at end of file diff --git a/app/routers/contact.py b/app/routers/contact.py new file mode 100644 index 0000000000000000000000000000000000000000..0f6aa965d7517ad397d7413229050a45203064be --- /dev/null +++ b/app/routers/contact.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List +from app.database import get_db +from app.schemas.contact import ContactCreate, ContactMessage as ContactSchema, ContactReplyCreate +from app.services.contact_service import create_contact_message, get_all_contact_messages, reply_to_message +from app.dependencies import get_current_user +from app.models.user import User + +router = APIRouter(prefix="/api/contact", tags=["contact"]) + +@router.post("/", response_model=ContactSchema) +def submit_contact_form(msg: ContactCreate, db: Session = Depends(get_db)): + return create_contact_message(db, msg) + +@router.get("/", response_model=List[ContactSchema]) +def get_contact_messages( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + if current_user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Contact messages accessible by admin only" + ) + return get_all_contact_messages(db) + +@router.post("/{message_id}/reply", response_model=ContactSchema) +def reply_to_contact( + message_id: int, + reply_data: ContactReplyCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + if current_user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only admins can reply to messages" + ) + + msg = reply_to_message(db, message_id, reply_data.reply) + if not msg: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Message not found" + ) + + return msg diff --git a/app/routers/content.py b/app/routers/content.py new file mode 100644 index 0000000000000000000000000000000000000000..a4a98bc0b1757167751cf15339c1f7c00e99ca24 --- /dev/null +++ b/app/routers/content.py @@ -0,0 +1,157 @@ +""" +Content upload, ingestion, and passage retrieval. +""" + +import uuid +from typing import List +from pathlib import Path + +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.dependencies import InstructorUser +from app.models.content import ContentDocument, ContentPassage +from app.models.course import Course +from app.schemas.content import DocumentOut, PassageOut, IngestionStatus +from app.services.content_ingestion import ContentIngestionService +from app.services.vector_store import VectorStoreService + +router = APIRouter(prefix="/api/content", tags=["Content Ingestion"]) + +ALLOWED_TYPES = {"pdf", "docx", "pptx"} + + +@router.post("/upload/{course_id}", response_model=DocumentOut, status_code=status.HTTP_201_CREATED) +async def upload_document( + course_id: int, + file: UploadFile = File(...), + user: InstructorUser = None, # type: ignore + db: Session = Depends(get_db), +): + course = db.query(Course).filter(Course.id == course_id).first() + if not course: + raise HTTPException(status_code=404, detail="Course not found") + if course.instructor_id != user.id and user.role != "admin": + raise HTTPException(status_code=403, detail="Not your course") + + ext = file.filename.rsplit(".", 1)[-1].lower() if file.filename else "" + if ext not in ALLOWED_TYPES: + raise HTTPException(status_code=400, detail=f"File type .{ext} not allowed. Use: {ALLOWED_TYPES}") + + # Read and save + content_bytes = await file.read() + if len(content_bytes) > settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024: + raise HTTPException(status_code=400, detail="File too large") + + filename = f"{uuid.uuid4().hex}.{ext}" + save_path = Path(settings.UPLOAD_DIR) / filename + save_path.write_bytes(content_bytes) + + doc = ContentDocument( + course_id=course_id, + filename=filename, + original_filename=file.filename or "unknown", + file_type=ext, + file_size=len(content_bytes), + uploaded_by=user.id, + ) + db.add(doc) + db.commit() + db.refresh(doc) + return doc + + +@router.post("/ingest/{document_id}", response_model=IngestionStatus) +def ingest_document( + document_id: int, + user: InstructorUser = None, # type: ignore + db: Session = Depends(get_db), +): + doc = db.query(ContentDocument).filter(ContentDocument.id == document_id).first() + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + + doc.upload_status = "processing" + db.commit() + + try: + ingestion_svc = ContentIngestionService() + file_path = Path(settings.UPLOAD_DIR) / doc.filename + passages_data = ingestion_svc.parse_and_chunk(str(file_path), doc.file_type) + + vs_service = VectorStoreService() + passage_records: list[ContentPassage] = [] + + for idx, pdata in enumerate(passages_data): + passage = ContentPassage( + document_id=doc.id, + content=pdata["text"], + page_number=pdata.get("page"), + chunk_index=idx, + ) + db.add(passage) + db.flush() + + emb_id = vs_service.add_passage( + collection_name=f"course_{doc.course_id}", + passage_id=str(passage.id), + text=pdata["text"], + metadata={ + "document_id": doc.id, + "course_id": doc.course_id, + "page": pdata.get("page"), + "chunk_index": idx, + }, + ) + passage.embedding_id = emb_id + passage_records.append(passage) + + doc.upload_status = "indexed" + db.commit() + + return IngestionStatus( + document_id=doc.id, + status="indexed", + passages_created=len(passage_records), + message="Content ingested and indexed successfully", + ) + except Exception as e: + doc.upload_status = "failed" + db.commit() + raise HTTPException(status_code=500, detail=f"Ingestion failed: {str(e)}") + + +@router.get("/documents/{course_id}", response_model=List[DocumentOut]) +def list_documents(course_id: int, user: InstructorUser = None, db: Session = Depends(get_db)): # type: ignore + return db.query(ContentDocument).filter(ContentDocument.course_id == course_id).all() + + +@router.get("/passages/{document_id}", response_model=List[PassageOut]) +def list_passages(document_id: int, user: InstructorUser = None, db: Session = Depends(get_db)): # type: ignore + return db.query(ContentPassage).filter(ContentPassage.document_id == document_id).all() + + +@router.delete("/documents/{document_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_document(document_id: int, user: InstructorUser = None, db: Session = Depends(get_db)): # type: ignore + doc = db.query(ContentDocument).filter(ContentDocument.id == document_id).first() + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + + # Remove from vector store + try: + vs_service = VectorStoreService() + passage_ids = [str(p.id) for p in doc.passages] + if passage_ids: + vs_service.delete_passages(f"course_{doc.course_id}", passage_ids) + except Exception: + pass + + # Remove file + file_path = Path(settings.UPLOAD_DIR) / doc.filename + if file_path.exists(): + file_path.unlink() + + db.delete(doc) + db.commit() \ No newline at end of file diff --git a/app/routers/courses.py b/app/routers/courses.py new file mode 100644 index 0000000000000000000000000000000000000000..1d0590c49d6e56ff4ba021428561caa13a1b8632 --- /dev/null +++ b/app/routers/courses.py @@ -0,0 +1,276 @@ +""" +Course CRUD + enrollment — with student search and flexible enrollment. +""" + +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from sqlalchemy import or_ + +from app.database import get_db +from app.dependencies import CurrentUser, InstructorUser +from app.models.course import Course, CourseEnrollment +from app.models.user import User +from app.schemas.course import ( + CourseCreate, CourseUpdate, CourseOut, + EnrollmentCreate, EnrollmentOut, +) + +router = APIRouter(prefix="/api/courses", tags=["Courses"]) + + +# ═══════════════════════════════════════════ +# COURSE CRUD +# ═══════════════════════════════════════════ + + +@router.post("/", response_model=CourseOut, status_code=status.HTTP_201_CREATED) +def create_course(payload: CourseCreate, user: InstructorUser, db: Session = Depends(get_db)): + existing = db.query(Course).filter(Course.code == payload.code).first() + if existing: + raise HTTPException(status_code=400, detail="Course code already exists") + course = Course(**payload.model_dump(), instructor_id=user.id) + db.add(course) + db.commit() + db.refresh(course) + return course + + +@router.get("/", response_model=List[CourseOut]) +def list_courses(current_user: CurrentUser, db: Session = Depends(get_db)): + if current_user.role in ("admin",): + return db.query(Course).all() + if current_user.role == "instructor": + return db.query(Course).filter(Course.instructor_id == current_user.id).all() + # student — enrolled courses + enrollments = db.query(CourseEnrollment).filter( + CourseEnrollment.student_id == current_user.id + ).all() + course_ids = [e.course_id for e in enrollments] + if not course_ids: + return [] + return db.query(Course).filter(Course.id.in_(course_ids)).all() + + +@router.get("/{course_id}", response_model=CourseOut) +def get_course(course_id: int, current_user: CurrentUser, db: Session = Depends(get_db)): + course = db.query(Course).filter(Course.id == course_id).first() + if not course: + raise HTTPException(status_code=404, detail="Course not found") + return course + + +@router.patch("/{course_id}", response_model=CourseOut) +def update_course( + course_id: int, payload: CourseUpdate, + user: InstructorUser, db: Session = Depends(get_db), +): + course = db.query(Course).filter(Course.id == course_id).first() + if not course: + raise HTTPException(status_code=404, detail="Course not found") + if course.instructor_id != user.id and user.role != "admin": + raise HTTPException(status_code=403, detail="Not your course") + for k, v in payload.model_dump(exclude_unset=True).items(): + setattr(course, k, v) + db.commit() + db.refresh(course) + return course + + +@router.delete("/{course_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_course(course_id: int, user: InstructorUser, db: Session = Depends(get_db)): + course = db.query(Course).filter(Course.id == course_id).first() + if not course: + raise HTTPException(status_code=404, detail="Course not found") + if course.instructor_id != user.id and user.role != "admin": + raise HTTPException(status_code=403, detail="Not your course") + db.delete(course) + db.commit() + + +# ═══════════════════════════════════════════ +# STUDENT SEARCH (for enrollment UI) +# ═══════════════════════════════════════════ + + +@router.get("/{course_id}/search-students") +def search_students( + course_id: int, + q: str = Query(default="", min_length=0, description="Search by name, email, or username"), + user: InstructorUser = None, + db: Session = Depends(get_db), +): + """ + Search for students to enroll. + Returns students NOT already enrolled in this course. + """ + # Get already enrolled student IDs + enrolled_ids = [ + e.student_id for e in + db.query(CourseEnrollment.student_id) + .filter(CourseEnrollment.course_id == course_id) + .all() + ] + + query = db.query(User).filter(User.role == "student", User.is_active == True) # noqa: E712 + + # Exclude already enrolled + if enrolled_ids: + query = query.filter(User.id.notin_(enrolled_ids)) + + # Apply search filter + if q and q.strip(): + search = f"%{q.strip()}%" + query = query.filter( + or_( + User.full_name.ilike(search), + User.email.ilike(search), + User.username.ilike(search), + ) + ) + + students = query.limit(20).all() + + return [ + { + "id": s.id, + "full_name": s.full_name, + "email": s.email, + "username": s.username, + } + for s in students + ] + + +# ═══════════════════════════════════════════ +# ENROLLMENT +# ═══════════════════════════════════════════ + + +def _resolve_student(payload: EnrollmentCreate, db: Session) -> User: + """Find the student by ID, username, or email.""" + student = None + + if payload.student_id: + student = db.query(User).filter( + User.id == payload.student_id, + User.role == "student", + ).first() + + if not student and payload.username: + student = db.query(User).filter( + User.username == payload.username, + User.role == "student", + ).first() + + if not student and payload.email: + student = db.query(User).filter( + User.email == payload.email, + User.role == "student", + ).first() + + # Last resort: try the student_id as username search + if not student and payload.student_id: + # Maybe they typed a username into the ID field + student = db.query(User).filter( + User.username == str(payload.student_id), + User.role == "student", + ).first() + + return student + + +@router.post("/{course_id}/enroll", status_code=201) +def enroll_student( + course_id: int, + payload: EnrollmentCreate, + user: InstructorUser, + db: Session = Depends(get_db), +): + course = db.query(Course).filter(Course.id == course_id).first() + if not course: + raise HTTPException(status_code=404, detail="Course not found") + + # Resolve student from ID, username, or email + student = _resolve_student(payload, db) + if not student: + raise HTTPException( + status_code=404, + detail="Student not found. Search by name, email, or username using the search field.", + ) + + # Check duplicate enrollment + existing = ( + db.query(CourseEnrollment) + .filter( + CourseEnrollment.course_id == course_id, + CourseEnrollment.student_id == student.id, + ) + .first() + ) + if existing: + raise HTTPException(status_code=400, detail=f"{student.full_name} is already enrolled") + + enrollment = CourseEnrollment(course_id=course_id, student_id=student.id) + db.add(enrollment) + db.commit() + db.refresh(enrollment) + + return { + "id": enrollment.id, + "course_id": enrollment.course_id, + "student_id": enrollment.student_id, + "enrolled_at": enrollment.enrolled_at.isoformat(), + "student_name": student.full_name, + "student_email": student.email, + "student_username": student.username, + } + + +@router.get("/{course_id}/students") +def list_enrolled( + course_id: int, + user: InstructorUser = None, + db: Session = Depends(get_db), +): + """List enrolled students with their names and details.""" + enrollments = ( + db.query(CourseEnrollment) + .filter(CourseEnrollment.course_id == course_id) + .all() + ) + + result = [] + for e in enrollments: + student = db.query(User).filter(User.id == e.student_id).first() + result.append({ + "id": e.id, + "course_id": e.course_id, + "student_id": e.student_id, + "enrolled_at": e.enrolled_at.isoformat() if e.enrolled_at else None, + "student_name": student.full_name if student else f"User #{e.student_id}", + "student_email": student.email if student else "", + "student_username": student.username if student else "", + }) + return result + + +@router.delete("/{course_id}/enroll/{student_id}", status_code=204) +def unenroll_student( + course_id: int, student_id: int, + user: InstructorUser, + db: Session = Depends(get_db), +): + enrollment = ( + db.query(CourseEnrollment) + .filter( + CourseEnrollment.course_id == course_id, + CourseEnrollment.student_id == student_id, + ) + .first() + ) + if not enrollment: + raise HTTPException(status_code=404, detail="Enrollment not found") + db.delete(enrollment) + db.commit() \ No newline at end of file diff --git a/app/routers/exams.py b/app/routers/exams.py new file mode 100644 index 0000000000000000000000000000000000000000..a9d0ec3ee4e19fb1a339c27bc449a70eb747d70c --- /dev/null +++ b/app/routers/exams.py @@ -0,0 +1,156 @@ +""" +Exam CRUD, publish, assign. +""" + +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.database import get_db +from app.dependencies import CurrentUser, InstructorUser +from app.models.exam import Exam, ExamAssignment +from app.models.course import Course, CourseEnrollment +from app.schemas.exam import ExamCreate, ExamUpdate, ExamOut, ExamAssign +from app.schemas.question import QuestionStudentView + +router = APIRouter(prefix="/api/exams", tags=["Exams"]) + + +@router.post("/", response_model=ExamOut, status_code=status.HTTP_201_CREATED) +def create_exam(payload: ExamCreate, user: InstructorUser, db: Session = Depends(get_db)): + course = db.query(Course).filter(Course.id == payload.course_id).first() + if not course: + raise HTTPException(status_code=404, detail="Course not found") + exam = Exam(**payload.model_dump(), created_by=user.id) + db.add(exam) + db.commit() + db.refresh(exam) + return exam + + +@router.get("/", response_model=List[ExamOut]) +def list_exams( + course_id: int | None = None, + current_user: CurrentUser = None, # type: ignore + db: Session = Depends(get_db), +): + q = db.query(Exam) + if current_user.role == "instructor": + q = q.filter(Exam.created_by == current_user.id) + elif current_user.role == "student": + assigned = db.query(ExamAssignment.exam_id).filter( + ExamAssignment.student_id == current_user.id + ).subquery() + q = q.filter(Exam.id.in_(assigned), Exam.is_published == True) # noqa: E712 + if course_id: + q = q.filter(Exam.course_id == course_id) + return q.all() + + +@router.get("/{exam_id}", response_model=ExamOut) +def get_exam(exam_id: int, current_user: CurrentUser, db: Session = Depends(get_db)): + exam = db.query(Exam).filter(Exam.id == exam_id).first() + if not exam: + raise HTTPException(status_code=404, detail="Exam not found") + return exam + + +@router.patch("/{exam_id}", response_model=ExamOut) +def update_exam(exam_id: int, payload: ExamUpdate, user: InstructorUser, db: Session = Depends(get_db)): + exam = db.query(Exam).filter(Exam.id == exam_id).first() + if not exam: + raise HTTPException(status_code=404, detail="Exam not found") + if exam.created_by != user.id and user.role != "admin": + raise HTTPException(status_code=403, detail="Not your exam") + for k, v in payload.model_dump(exclude_unset=True).items(): + setattr(exam, k, v) + db.commit() + db.refresh(exam) + return exam + + +@router.post("/{exam_id}/publish", response_model=ExamOut) +def publish_exam(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)): + exam = db.query(Exam).filter(Exam.id == exam_id).first() + if not exam: + raise HTTPException(status_code=404, detail="Exam not found") + if not exam.questions: + raise HTTPException(status_code=400, detail="Add questions before publishing") + exam.is_published = True + db.commit() + db.refresh(exam) + return exam + + +@router.post("/{exam_id}/unpublish", response_model=ExamOut) +def unpublish_exam(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)): + exam = db.query(Exam).filter(Exam.id == exam_id).first() + if not exam: + raise HTTPException(status_code=404, detail="Exam not found") + exam.is_published = False + db.commit() + db.refresh(exam) + return exam + + +@router.post("/{exam_id}/assign") +def assign_students(exam_id: int, payload: ExamAssign, user: InstructorUser, db: Session = Depends(get_db)): + exam = db.query(Exam).filter(Exam.id == exam_id).first() + if not exam: + raise HTTPException(status_code=404, detail="Exam not found") + + created = 0 + for sid in payload.student_ids: + existing = ( + db.query(ExamAssignment) + .filter(ExamAssignment.exam_id == exam_id, ExamAssignment.student_id == sid) + .first() + ) + if not existing: + db.add(ExamAssignment(exam_id=exam_id, student_id=sid)) + created += 1 + db.commit() + return {"assigned": created, "total_requested": len(payload.student_ids)} + + +@router.post("/{exam_id}/assign-all") +def assign_all_enrolled(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)): + """Assign all enrolled students of the exam's course.""" + exam = db.query(Exam).filter(Exam.id == exam_id).first() + if not exam: + raise HTTPException(status_code=404, detail="Exam not found") + + enrollments = db.query(CourseEnrollment).filter(CourseEnrollment.course_id == exam.course_id).all() + created = 0 + for enrollment in enrollments: + existing = ( + db.query(ExamAssignment) + .filter(ExamAssignment.exam_id == exam_id, ExamAssignment.student_id == enrollment.student_id) + .first() + ) + if not existing: + db.add(ExamAssignment(exam_id=exam_id, student_id=enrollment.student_id)) + created += 1 + db.commit() + return {"assigned": created, "total_enrolled": len(enrollments)} + + +@router.delete("/{exam_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_exam(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)): + exam = db.query(Exam).filter(Exam.id == exam_id).first() + if not exam: + raise HTTPException(status_code=404, detail="Exam not found") + if exam.created_by != user.id and user.role != "admin": + raise HTTPException(status_code=403, detail="Not your exam") + db.delete(exam) + db.commit() + + +@router.get("/{exam_id}/questions-student", response_model=List[QuestionStudentView]) +def get_exam_questions_student(exam_id: int, current_user: CurrentUser, db: Session = Depends(get_db)): + """Return questions without correct answers (student view).""" + exam = db.query(Exam).filter(Exam.id == exam_id, Exam.is_published == True).first() # noqa: E712 + if not exam: + raise HTTPException(status_code=404, detail="Exam not found or not published") + return exam.questions \ No newline at end of file diff --git a/app/routers/grading.py b/app/routers/grading.py new file mode 100644 index 0000000000000000000000000000000000000000..5dfdc89f1105a2d837feeb2f05d21c82e808eb73 --- /dev/null +++ b/app/routers/grading.py @@ -0,0 +1,152 @@ +""" +Grading endpoints — auto-grade, batch grade, manual override, confidence review. +""" + +from typing import List +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.database import get_db +from app.dependencies import InstructorUser +from app.models.submission import ExamSubmission, AnswerResponse +from app.schemas.submission import SubmissionDetail, SubmissionOut, AnswerResponseOut +from app.services.grading_service import GradingService +from app.config import settings + +router = APIRouter(prefix="/api/grading", tags=["Grading"]) + + +@router.post("/auto/{submission_id}", response_model=SubmissionDetail) +def auto_grade(submission_id: int, user: InstructorUser, db: Session = Depends(get_db)): + submission = db.query(ExamSubmission).filter(ExamSubmission.id == submission_id).first() + if not submission: + raise HTTPException(status_code=404, detail="Submission not found") + if submission.status not in ("submitted", "graded"): + raise HTTPException(status_code=400, detail="Submission not yet submitted") + + grading_svc = GradingService(db) + grading_svc.grade_submission(submission) + + answers = db.query(AnswerResponse).filter(AnswerResponse.submission_id == submission.id).all() + return SubmissionDetail( + submission=SubmissionOut.model_validate(submission), + answers=[AnswerResponseOut.model_validate(a) for a in answers], + ) + + +@router.post("/auto/exam/{exam_id}") +def auto_grade_all(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)): + submissions = ( + db.query(ExamSubmission) + .filter(ExamSubmission.exam_id == exam_id, ExamSubmission.status == "submitted") + .all() + ) + grading_svc = GradingService(db) + graded = 0 + failed = 0 + for sub in submissions: + try: + grading_svc.grade_submission(sub) + graded += 1 + except Exception as e: + failed += 1 + return { + "graded": graded, + "failed": failed, + "total_submitted": len(submissions), + "grading_mode": settings.GRADING_MODE, + "llm_model": settings.NVIDIA_LLM_MODEL, + } + + +@router.get("/low-confidence/{exam_id}") +def get_low_confidence_answers( + exam_id: int, + user: InstructorUser, + threshold: float = Query(default=0.7, ge=0.0, le=1.0), + db: Session = Depends(get_db), +): + """Get answers where AI grading confidence is below threshold — need human review.""" + submissions = ( + db.query(ExamSubmission) + .filter(ExamSubmission.exam_id == exam_id, ExamSubmission.status == "graded") + .all() + ) + sub_ids = [s.id for s in submissions] + if not sub_ids: + return [] + + low_conf = ( + db.query(AnswerResponse) + .filter( + AnswerResponse.submission_id.in_(sub_ids), + AnswerResponse.confidence_score < threshold, + AnswerResponse.confidence_score.isnot(None), + ) + .all() + ) + return [ + { + "answer_id": a.id, + "submission_id": a.submission_id, + "question_id": a.question_id, + "student_answer": a.student_answer, + "current_score": a.score, + "max_score": a.max_score, + "confidence": a.confidence_score, + "ai_feedback": a.ai_feedback, + } + for a in low_conf + ] + + +@router.patch("/manual/{answer_id}") +def manual_override( + answer_id: int, + score: float, + user: InstructorUser, + feedback: str | None = None, + db: Session = Depends(get_db), +): + answer = db.query(AnswerResponse).filter(AnswerResponse.id == answer_id).first() + if not answer: + raise HTTPException(status_code=404, detail="Answer not found") + answer.score = min(score, answer.max_score) + answer.is_correct = score >= (answer.max_score * 0.7) + answer.confidence_score = 1.0 # Manual = full confidence + if feedback: + answer.ai_feedback = f"[Instructor override] {feedback}" + else: + answer.ai_feedback = (answer.ai_feedback or "") + " [Score overridden by instructor]" + db.commit() + + # Recalculate submission totals + submission = db.query(ExamSubmission).filter(ExamSubmission.id == answer.submission_id).first() + if submission: + all_answers = db.query(AnswerResponse).filter(AnswerResponse.submission_id == submission.id).all() + submission.total_score = round(sum(a.score for a in all_answers), 2) + submission.max_score = round(sum(a.max_score for a in all_answers), 2) + submission.percentage = round( + (submission.total_score / submission.max_score * 100) if submission.max_score else 0, 2 + ) + exam = submission.exam + passing_pct = (exam.passing_marks / exam.total_marks * 100) if exam and exam.total_marks else 40 + submission.is_passed = submission.percentage >= passing_pct + db.commit() + + return {"status": "updated", "new_score": answer.score, "confidence": 1.0} + + +@router.get("/config") +def grading_config(user: InstructorUser): + """Return current grading configuration.""" + return { + "llm_provider": settings.LLM_PROVIDER, + "llm_model": settings.NVIDIA_LLM_MODEL, + "embed_model": settings.NVIDIA_EMBED_MODEL, + "rerank_model": settings.NVIDIA_RERANK_MODEL, + "grading_mode": settings.GRADING_MODE, + "confidence_threshold": settings.GRADING_CONFIDENCE_THRESHOLD, + "rubric_grading": settings.ENABLE_RUBRIC_GRADING, + "use_reranker": settings.USE_RERANKER, + } \ No newline at end of file diff --git a/app/routers/questions.py b/app/routers/questions.py new file mode 100644 index 0000000000000000000000000000000000000000..bac590f01138227b016875bf17d3e6aae0173749 --- /dev/null +++ b/app/routers/questions.py @@ -0,0 +1,99 @@ +""" +Question CRUD + AI generation. +""" + +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.database import get_db +from app.dependencies import InstructorUser +from app.models.question import ExamQuestion +from app.models.exam import Exam +from app.schemas.question import ( + QuestionCreate, + QuestionUpdate, + QuestionOut, + GenerateQuestionsRequest, +) +from app.services.question_generator import QuestionGeneratorService + +router = APIRouter(prefix="/api/questions", tags=["Questions"]) + + +@router.post("/", response_model=QuestionOut, status_code=status.HTTP_201_CREATED) +def create_question(payload: QuestionCreate, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore + exam = db.query(Exam).filter(Exam.id == payload.exam_id).first() + if not exam: + raise HTTPException(status_code=404, detail="Exam not found") + question = ExamQuestion(**payload.model_dump()) + db.add(question) + db.commit() + db.refresh(question) + return question + + +@router.get("/exam/{exam_id}", response_model=List[QuestionOut]) +def list_questions(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore + return ( + db.query(ExamQuestion) + .filter(ExamQuestion.exam_id == exam_id) + .order_by(ExamQuestion.order_index) + .all() + ) + + +@router.get("/{question_id}", response_model=QuestionOut) +def get_question(question_id: int, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore + q = db.query(ExamQuestion).filter(ExamQuestion.id == question_id).first() + if not q: + raise HTTPException(status_code=404, detail="Question not found") + return q + + +@router.patch("/{question_id}", response_model=QuestionOut) +def update_question(question_id: int, payload: QuestionUpdate, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore + q = db.query(ExamQuestion).filter(ExamQuestion.id == question_id).first() + if not q: + raise HTTPException(status_code=404, detail="Question not found") + for k, v in payload.model_dump(exclude_unset=True).items(): + setattr(q, k, v) + db.commit() + db.refresh(q) + return q + + +@router.delete("/{question_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_question(question_id: int, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore + q = db.query(ExamQuestion).filter(ExamQuestion.id == question_id).first() + if not q: + raise HTTPException(status_code=404, detail="Question not found") + db.delete(q) + db.commit() + + +@router.post("/generate", response_model=List[QuestionOut]) +def generate_questions( + payload: GenerateQuestionsRequest, + user: InstructorUser, # type: ignore + db: Session = Depends(get_db), +): + """Use RAG + LLM to generate questions from course content.""" + exam = db.query(Exam).filter(Exam.id == payload.exam_id).first() + if not exam: + raise HTTPException(status_code=404, detail="Exam not found") + + try: + gen_service = QuestionGeneratorService(db) + questions = gen_service.generate( + course_id=payload.course_id, + exam_id=payload.exam_id, + num_questions=payload.num_questions, + question_type=payload.question_type, + difficulty=payload.difficulty, + topic=payload.topic, + ) + return questions + except Exception as e: + raise HTTPException(status_code=500, detail=f"Generation failed: {str(e)}") \ No newline at end of file diff --git a/app/routers/submissions.py b/app/routers/submissions.py new file mode 100644 index 0000000000000000000000000000000000000000..ab179e8cc56efb19bacdea640f6556d437c6ee38 --- /dev/null +++ b/app/routers/submissions.py @@ -0,0 +1,241 @@ +""" +Exam‑taking flow: start, autosave, submit, activity events. +""" + +from datetime import datetime, timezone +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from app.database import get_db +from app.dependencies import CurrentUser +from app.models.exam import Exam, ExamAssignment +from app.models.submission import ExamSubmission, AnswerResponse +from app.models.question import ExamQuestion +from app.models.activity_log import ActivityLog +from app.schemas.submission import ( + SubmissionStart, + SubmissionOut, + SubmissionDetail, + AnswerResponseOut, + AutosaveRequest, + ActivityEvent, +) + +router = APIRouter(prefix="/api/submissions", tags=["Submissions"]) + + +@router.post("/start", response_model=SubmissionOut, status_code=status.HTTP_201_CREATED) +def start_exam( + payload: SubmissionStart, + current_user: CurrentUser, + request: Request, + db: Session = Depends(get_db), +): + exam = db.query(Exam).filter(Exam.id == payload.exam_id, Exam.is_published == True).first() # noqa: E712 + if not exam: + raise HTTPException(status_code=404, detail="Exam not found or not published") + + # Check assignment + assigned = ( + db.query(ExamAssignment) + .filter(ExamAssignment.exam_id == exam.id, ExamAssignment.student_id == current_user.id) + .first() + ) + if not assigned and current_user.role == "student": + raise HTTPException(status_code=403, detail="You are not assigned to this exam") + + # Check max attempts + existing_count = ( + db.query(ExamSubmission) + .filter( + ExamSubmission.exam_id == exam.id, + ExamSubmission.student_id == current_user.id, + ExamSubmission.status.in_(["submitted", "graded"]), + ) + .count() + ) + if existing_count >= exam.max_attempts: + raise HTTPException(status_code=400, detail="Maximum attempts reached") + + # Check for in-progress submission + in_progress = ( + db.query(ExamSubmission) + .filter( + ExamSubmission.exam_id == exam.id, + ExamSubmission.student_id == current_user.id, + ExamSubmission.status == "in_progress", + ) + .first() + ) + if in_progress: + return in_progress + + submission = ExamSubmission(exam_id=exam.id, student_id=current_user.id) + db.add(submission) + db.commit() + db.refresh(submission) + + # Log + db.add(ActivityLog( + user_id=current_user.id, + exam_id=exam.id, + submission_id=submission.id, + action_type="exam_started", + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + )) + db.commit() + + return submission + + +@router.post("/{submission_id}/autosave") +def autosave_answers( + submission_id: int, + payload: AutosaveRequest, + current_user: CurrentUser, + db: Session = Depends(get_db), +): + submission = db.query(ExamSubmission).filter( + ExamSubmission.id == submission_id, + ExamSubmission.student_id == current_user.id, + ExamSubmission.status == "in_progress", + ).first() + if not submission: + raise HTTPException(status_code=404, detail="Active submission not found") + + for ans in payload.answers: + existing = ( + db.query(AnswerResponse) + .filter( + AnswerResponse.submission_id == submission.id, + AnswerResponse.question_id == ans.question_id, + ) + .first() + ) + question = db.query(ExamQuestion).filter(ExamQuestion.id == ans.question_id).first() + if not question: + continue + if existing: + existing.student_answer = ans.student_answer + else: + db.add(AnswerResponse( + submission_id=submission.id, + question_id=ans.question_id, + student_answer=ans.student_answer, + max_score=question.marks, + )) + db.commit() + return {"status": "saved", "answers_count": len(payload.answers)} + + +@router.post("/{submission_id}/submit", response_model=SubmissionOut) +def submit_exam( + submission_id: int, + payload: AutosaveRequest, + current_user: CurrentUser, + request: Request, + db: Session = Depends(get_db), +): + submission = db.query(ExamSubmission).filter( + ExamSubmission.id == submission_id, + ExamSubmission.student_id == current_user.id, + ExamSubmission.status == "in_progress", + ).first() + if not submission: + raise HTTPException(status_code=404, detail="Active submission not found") + + # Save final answers + for ans in payload.answers: + existing = ( + db.query(AnswerResponse) + .filter( + AnswerResponse.submission_id == submission.id, + AnswerResponse.question_id == ans.question_id, + ) + .first() + ) + question = db.query(ExamQuestion).filter(ExamQuestion.id == ans.question_id).first() + if not question: + continue + if existing: + existing.student_answer = ans.student_answer + else: + db.add(AnswerResponse( + submission_id=submission.id, + question_id=ans.question_id, + student_answer=ans.student_answer, + max_score=question.marks, + )) + + submission.status = "submitted" + submission.submitted_at = datetime.now(timezone.utc) + db.commit() + db.refresh(submission) + + # Log + db.add(ActivityLog( + user_id=current_user.id, + exam_id=submission.exam_id, + submission_id=submission.id, + action_type="exam_submitted", + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + )) + db.commit() + + return submission + + +@router.get("/{submission_id}", response_model=SubmissionDetail) +def get_submission(submission_id: int, current_user: CurrentUser, db: Session = Depends(get_db)): + submission = db.query(ExamSubmission).filter(ExamSubmission.id == submission_id).first() + if not submission: + raise HTTPException(status_code=404, detail="Submission not found") + if current_user.role == "student" and submission.student_id != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + answers = db.query(AnswerResponse).filter(AnswerResponse.submission_id == submission.id).all() + return SubmissionDetail( + submission=SubmissionOut.model_validate(submission), + answers=[AnswerResponseOut.model_validate(a) for a in answers], + ) + + +@router.get("/exam/{exam_id}", response_model=List[SubmissionOut]) +def list_exam_submissions(exam_id: int, current_user: CurrentUser, db: Session = Depends(get_db)): + q = db.query(ExamSubmission).filter(ExamSubmission.exam_id == exam_id) + if current_user.role == "student": + q = q.filter(ExamSubmission.student_id == current_user.id) + return q.all() + + +@router.get("/my/all", response_model=List[SubmissionOut]) +def my_submissions(current_user: CurrentUser, db: Session = Depends(get_db)): + return db.query(ExamSubmission).filter(ExamSubmission.student_id == current_user.id).all() + + +# ── Activity event logging from secure client ── + + +@router.post("/activity", status_code=status.HTTP_201_CREATED) +def log_activity( + payload: ActivityEvent, + current_user: CurrentUser, + request: Request, + db: Session = Depends(get_db), +): + log = ActivityLog( + user_id=current_user.id, + exam_id=payload.exam_id, + submission_id=payload.submission_id, + action_type=payload.action_type, + details=payload.details, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + db.add(log) + db.commit() + return {"status": "logged"} \ No newline at end of file diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000000000000000000000000000000000000..36af7e4068645cb4af9d5376c5df5f45b4963c4a --- /dev/null +++ b/app/routers/users.py @@ -0,0 +1,74 @@ +""" +User management endpoints. +""" + +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.database import get_db +from app.dependencies import AdminUser, CurrentUser +from app.models.user import User +from app.schemas.user import UserOut, UserUpdate + +router = APIRouter(prefix="/api/users", tags=["Users"]) + + +@router.get("/", response_model=List[UserOut]) +def list_users( + role: str | None = None, + skip: int = 0, + limit: int = 50, + _admin: AdminUser = None, # type: ignore + db: Session = Depends(get_db), +): + q = db.query(User) + if role: + q = q.filter(User.role == role) + return q.offset(skip).limit(limit).all() + + +@router.get("/{user_id}", response_model=UserOut) +def get_user(user_id: int, current_user: CurrentUser, db: Session = Depends(get_db)): + if current_user.role != "admin" and current_user.id != user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.patch("/{user_id}", response_model=UserOut) +def update_user( + user_id: int, + payload: UserUpdate, + current_user: CurrentUser, + db: Session = Depends(get_db), +): + if current_user.role != "admin" and current_user.id != user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + update_data = payload.model_dump(exclude_unset=True) + # Only admins can change role/active status + if current_user.role != "admin": + update_data.pop("role", None) + update_data.pop("is_active", None) + + for k, v in update_data.items(): + setattr(user, k, v) + db.commit() + db.refresh(user) + return user + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user(user_id: int, _admin: AdminUser, db: Session = Depends(get_db)): # type: ignore + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + db.delete(user) + db.commit() \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/schemas/analytics.py b/app/schemas/analytics.py new file mode 100644 index 0000000000000000000000000000000000000000..6f4609a8ebde781c0e7122c13fee5e2832ea3d6f --- /dev/null +++ b/app/schemas/analytics.py @@ -0,0 +1,46 @@ +from typing import Optional, List, Dict + +from pydantic import BaseModel + + +class ExamAnalytics(BaseModel): + exam_id: int + exam_title: str + total_students: int + submitted_count: int + graded_count: int + average_score: Optional[float] + highest_score: Optional[float] + lowest_score: Optional[float] + pass_rate: Optional[float] + score_distribution: Dict[str, int] + + +class QuestionAnalytics(BaseModel): + question_id: int + question_text: str + question_type: str + total_attempts: int + correct_count: int + accuracy_rate: float + average_score: float + difficulty_rating: str + + +class StudentPerformance(BaseModel): + student_id: int + student_name: str + exams_taken: int + average_score: float + highest_score: float + lowest_score: float + weak_areas: List[str] + + +class CourseAnalytics(BaseModel): + course_id: int + course_title: str + total_exams: int + total_students: int + overall_average: Optional[float] + exam_summaries: List[ExamAnalytics] \ No newline at end of file diff --git a/app/schemas/contact.py b/app/schemas/contact.py new file mode 100644 index 0000000000000000000000000000000000000000..a4302e2439c77e4fa2cccb28aa2e4609a1cbab8f --- /dev/null +++ b/app/schemas/contact.py @@ -0,0 +1,32 @@ +from pydantic import EmailStr, BaseModel +from datetime import datetime +from typing import Optional, List + +class ContactCreate(BaseModel): + name: str + email: EmailStr + subject: str + message: str + +class ContactReplyCreate(BaseModel): + reply: str + +class ContactReply(BaseModel): + id: int + content: str + created_at: datetime + + class Config: + from_attributes = True + +class ContactMessage(BaseModel): + id: int + name: str + email: str + subject: str + message: str + created_at: datetime + replies: List[ContactReply] = [] + + class Config: + from_attributes = True diff --git a/app/schemas/content.py b/app/schemas/content.py new file mode 100644 index 0000000000000000000000000000000000000000..51000dd104a9e3230569cf44f8a8d4fcd7d71388 --- /dev/null +++ b/app/schemas/content.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +class DocumentOut(BaseModel): + id: int + course_id: int + filename: str + original_filename: str + file_type: str + file_size: int + upload_status: str + uploaded_by: int + created_at: datetime + + model_config = {"from_attributes": True} + + +class PassageOut(BaseModel): + id: int + document_id: int + content: str + page_number: Optional[int] + chunk_index: int + embedding_id: Optional[str] + created_at: datetime + + model_config = {"from_attributes": True} + + +class IngestionStatus(BaseModel): + document_id: int + status: str + passages_created: int + message: str \ No newline at end of file diff --git a/app/schemas/course.py b/app/schemas/course.py new file mode 100644 index 0000000000000000000000000000000000000000..c804e57d847972684eb3b642f544f41feab3fbf3 --- /dev/null +++ b/app/schemas/course.py @@ -0,0 +1,46 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class CourseCreate(BaseModel): + title: str = Field(min_length=1, max_length=255) + description: Optional[str] = None + code: str = Field(min_length=2, max_length=30) + + +class CourseUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + + +class CourseOut(BaseModel): + id: int + title: str + description: Optional[str] + code: str + instructor_id: int + created_at: datetime + + model_config = {"from_attributes": True} + + +class EnrollmentCreate(BaseModel): + """Accept student_id OR username OR email — backend resolves the student.""" + student_id: Optional[int] = None + username: Optional[str] = None + email: Optional[str] = None + + +class EnrollmentOut(BaseModel): + id: int + course_id: int + student_id: int + enrolled_at: datetime + # Include student details so frontend can display names + student_name: Optional[str] = None + student_email: Optional[str] = None + student_username: Optional[str] = None + + model_config = {"from_attributes": True} \ No newline at end of file diff --git a/app/schemas/exam.py b/app/schemas/exam.py new file mode 100644 index 0000000000000000000000000000000000000000..84e41c1bff9a9c86d02702d6d36064020071dd8e --- /dev/null +++ b/app/schemas/exam.py @@ -0,0 +1,55 @@ +from datetime import datetime +from typing import Optional, List + +from pydantic import BaseModel, Field + + +class ExamCreate(BaseModel): + course_id: int + title: str = Field(min_length=1, max_length=255) + description: Optional[str] = None + duration_minutes: int = Field(default=60, ge=5, le=480) + total_marks: float = Field(default=100, gt=0) + passing_marks: float = Field(default=40, ge=0) + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + shuffle_questions: bool = False + show_results: bool = True + max_attempts: int = Field(default=1, ge=1, le=10) + + +class ExamUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + duration_minutes: Optional[int] = None + total_marks: Optional[float] = None + passing_marks: Optional[float] = None + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + shuffle_questions: Optional[bool] = None + show_results: Optional[bool] = None + max_attempts: Optional[int] = None + + +class ExamOut(BaseModel): + id: int + course_id: int + title: str + description: Optional[str] + created_by: int + duration_minutes: int + total_marks: float + passing_marks: float + start_time: Optional[datetime] + end_time: Optional[datetime] + is_published: bool + shuffle_questions: bool + show_results: bool + max_attempts: int + created_at: datetime + + model_config = {"from_attributes": True} + + +class ExamAssign(BaseModel): + student_ids: List[int] \ No newline at end of file diff --git a/app/schemas/question.py b/app/schemas/question.py new file mode 100644 index 0000000000000000000000000000000000000000..21ba7d7dd6dac0a5db2f7cf0a8b570b3970c3e3c --- /dev/null +++ b/app/schemas/question.py @@ -0,0 +1,65 @@ +from datetime import datetime +from typing import Optional, Dict + +from pydantic import BaseModel, Field + + +class QuestionCreate(BaseModel): + exam_id: int + question_text: str + question_type: str = Field(pattern="^(mcq|short_answer|descriptive)$") + options: Optional[Dict[str, str]] = None # {"A":"...","B":"...","C":"...","D":"..."} + correct_answer: str + marks: float = Field(default=1.0, gt=0) + explanation: Optional[str] = None + difficulty: str = Field(default="medium", pattern="^(easy|medium|hard)$") + order_index: int = 0 + + +class QuestionUpdate(BaseModel): + question_text: Optional[str] = None + options: Optional[Dict[str, str]] = None + correct_answer: Optional[str] = None + marks: Optional[float] = None + explanation: Optional[str] = None + difficulty: Optional[str] = None + order_index: Optional[int] = None + + +class QuestionOut(BaseModel): + id: int + exam_id: int + question_text: str + question_type: str + options: Optional[Dict[str, str]] + correct_answer: str + marks: float + explanation: Optional[str] + difficulty: str + order_index: int + created_at: datetime + + model_config = {"from_attributes": True} + + +class QuestionStudentView(BaseModel): + """Same as QuestionOut but hides correct_answer and explanation.""" + id: int + exam_id: int + question_text: str + question_type: str + options: Optional[Dict[str, str]] + marks: float + difficulty: str + order_index: int + + model_config = {"from_attributes": True} + + +class GenerateQuestionsRequest(BaseModel): + course_id: int + exam_id: int + num_questions: int = Field(default=5, ge=1, le=50) + question_type: str = Field(default="mcq", pattern="^(mcq|short_answer|descriptive|mixed)$") + difficulty: str = Field(default="medium", pattern="^(easy|medium|hard|mixed)$") + topic: Optional[str] = None \ No newline at end of file diff --git a/app/schemas/submission.py b/app/schemas/submission.py new file mode 100644 index 0000000000000000000000000000000000000000..4388d73626ccc38ed71f66cecda38f42b6aa7b6c --- /dev/null +++ b/app/schemas/submission.py @@ -0,0 +1,59 @@ +from datetime import datetime +from typing import Optional, List, Dict + +from pydantic import BaseModel + + +class AnswerSubmit(BaseModel): + question_id: int + student_answer: str + + +class AutosaveRequest(BaseModel): + answers: List[AnswerSubmit] + + +class SubmissionStart(BaseModel): + exam_id: int + + +class SubmissionOut(BaseModel): + id: int + exam_id: int + student_id: int + started_at: datetime + submitted_at: Optional[datetime] + status: str + total_score: Optional[float] + max_score: Optional[float] + percentage: Optional[float] + is_passed: Optional[bool] + graded_at: Optional[datetime] + + model_config = {"from_attributes": True} + + +class AnswerResponseOut(BaseModel): + id: int + submission_id: int + question_id: int + student_answer: Optional[str] + is_correct: Optional[bool] + score: float + max_score: float + ai_feedback: Optional[str] + confidence_score: Optional[float] + + model_config = {"from_attributes": True} + + +class SubmissionDetail(BaseModel): + submission: SubmissionOut + answers: List[AnswerResponseOut] + + +class ActivityEvent(BaseModel): + exam_id: int + submission_id: int + action_type: str # tab_switch | copy_attempt | paste_attempt | right_click | focus_lost | focus_gained + details: Optional[Dict] = None \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000000000000000000000000000000000000..725d8eca17347dad202b8306e4f1ea81a09ef122 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,47 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field + + +class UserCreate(BaseModel): + email: EmailStr + username: str = Field(min_length=3, max_length=100) + password: str = Field(min_length=6, max_length=128) + full_name: str = Field(min_length=1, max_length=255) + role: str = Field(default="student", pattern="^(admin|instructor|student)$") + + +class UserLogin(BaseModel): + username: str + password: str + + +class UserUpdate(BaseModel): + full_name: Optional[str] = None + email: Optional[EmailStr] = None + is_active: Optional[bool] = None + role: Optional[str] = Field(default=None, pattern="^(admin|instructor|student)$") + + +class UserOut(BaseModel): + id: int + email: str + username: str + full_name: str + role: str + is_active: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class Token(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class TokenData(BaseModel): + sub: int + role: str \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/analytics_service.py b/app/services/analytics_service.py new file mode 100644 index 0000000000000000000000000000000000000000..36edd8978a3a6a486dab9f324566a42db7eec37e --- /dev/null +++ b/app/services/analytics_service.py @@ -0,0 +1,179 @@ +""" +Analytics computations for exams, questions, students, courses. +""" + +import logging +from typing import Optional, List + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.models.exam import Exam +from app.models.question import ExamQuestion +from app.models.submission import ExamSubmission, AnswerResponse +from app.models.course import Course +from app.models.user import User +from app.schemas.analytics import ( + ExamAnalytics, + QuestionAnalytics, + StudentPerformance, + CourseAnalytics, +) + +logger = logging.getLogger(__name__) + + +class AnalyticsService: + def __init__(self, db: Session): + self.db = db + + def get_exam_analytics(self, exam_id: int) -> Optional[ExamAnalytics]: + exam = self.db.query(Exam).filter(Exam.id == exam_id).first() + if not exam: + return None + + submissions = ( + self.db.query(ExamSubmission).filter(ExamSubmission.exam_id == exam_id).all() + ) + graded = [s for s in submissions if s.status == "graded"] + submitted = [s for s in submissions if s.status in ("submitted", "graded")] + scores = [s.percentage for s in graded if s.percentage is not None] + + # Score distribution buckets + dist = {"0-20": 0, "21-40": 0, "41-60": 0, "61-80": 0, "81-100": 0} + for s in scores: + if s <= 20: + dist["0-20"] += 1 + elif s <= 40: + dist["21-40"] += 1 + elif s <= 60: + dist["41-60"] += 1 + elif s <= 80: + dist["61-80"] += 1 + else: + dist["81-100"] += 1 + + pass_count = sum(1 for s in graded if s.is_passed) + + return ExamAnalytics( + exam_id=exam_id, + exam_title=exam.title, + total_students=len(submissions), + submitted_count=len(submitted), + graded_count=len(graded), + average_score=round(sum(scores) / len(scores), 2) if scores else None, + highest_score=max(scores) if scores else None, + lowest_score=min(scores) if scores else None, + pass_rate=round(pass_count / len(graded) * 100, 2) if graded else None, + score_distribution=dist, + ) + + def get_question_analytics(self, exam_id: int) -> List[QuestionAnalytics]: + questions = ( + self.db.query(ExamQuestion) + .filter(ExamQuestion.exam_id == exam_id) + .order_by(ExamQuestion.order_index) + .all() + ) + + analytics = [] + for q in questions: + answers = self.db.query(AnswerResponse).filter(AnswerResponse.question_id == q.id).all() + total = len(answers) + correct = sum(1 for a in answers if a.is_correct) + avg_score = sum(a.score for a in answers) / total if total else 0 + + accuracy = correct / total if total else 0 + if accuracy >= 0.8: + rating = "easy" + elif accuracy >= 0.5: + rating = "medium" + else: + rating = "hard" + + analytics.append(QuestionAnalytics( + question_id=q.id, + question_text=q.question_text[:200], + question_type=q.question_type, + total_attempts=total, + correct_count=correct, + accuracy_rate=round(accuracy, 3), + average_score=round(avg_score, 2), + difficulty_rating=rating, + )) + + return analytics + + def get_student_performance(self, student_id: int) -> Optional[StudentPerformance]: + student = self.db.query(User).filter(User.id == student_id).first() + if not student: + return None + + submissions = ( + self.db.query(ExamSubmission) + .filter(ExamSubmission.student_id == student_id, ExamSubmission.status == "graded") + .all() + ) + scores = [s.percentage for s in submissions if s.percentage is not None] + + # Weak areas: questions with low scores + weak_areas: List[str] = [] + if submissions: + low_answers = ( + self.db.query(AnswerResponse, ExamQuestion) + .join(ExamQuestion, AnswerResponse.question_id == ExamQuestion.id) + .filter( + AnswerResponse.submission_id.in_([s.id for s in submissions]), + AnswerResponse.is_correct == False, # noqa: E712 + ) + .limit(10) + .all() + ) + seen = set() + for answer, question in low_answers: + topic = question.question_text[:80] + if topic not in seen: + weak_areas.append(topic) + seen.add(topic) + + return StudentPerformance( + student_id=student_id, + student_name=student.full_name, + exams_taken=len(submissions), + average_score=round(sum(scores) / len(scores), 2) if scores else 0.0, + highest_score=max(scores) if scores else 0.0, + lowest_score=min(scores) if scores else 0.0, + weak_areas=weak_areas[:5], + ) + + def get_course_analytics(self, course_id: int) -> Optional[CourseAnalytics]: + course = self.db.query(Course).filter(Course.id == course_id).first() + if not course: + return None + + exams = self.db.query(Exam).filter(Exam.course_id == course_id).all() + exam_summaries = [] + all_scores = [] + + for exam in exams: + ea = self.get_exam_analytics(exam.id) + if ea: + exam_summaries.append(ea) + if ea.average_score is not None: + all_scores.append(ea.average_score) + + from app.models.course import CourseEnrollment + total_students = ( + self.db.query(func.count(CourseEnrollment.id)) + .filter(CourseEnrollment.course_id == course_id) + .scalar() + ) or 0 + + return CourseAnalytics( + course_id=course_id, + course_title=course.title, + total_exams=len(exams), + total_students=total_students, + overall_average=round(sum(all_scores) / len(all_scores), 2) if all_scores else None, + exam_summaries=exam_summaries, + ) \ No newline at end of file diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000000000000000000000000000000000000..b40dfd28dfb322cb1e4db318943b8b4250720e43 --- /dev/null +++ b/app/services/auth_service.py @@ -0,0 +1,84 @@ +""" +Authentication service: registration, login, JWT minting. +""" + +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt +from sqlalchemy.orm import Session +from fastapi import HTTPException, status + +from app.config import settings +from app.models.user import User +from app.schemas.user import UserCreate, Token +from app.utils.security import hash_password, verify_password + + +class AuthService: + def __init__(self, db: Session): + self.db = db + + # ── Register ── + def register(self, payload: UserCreate) -> User: + email = payload.email.strip() + username = payload.username.strip() + if self.db.query(User).filter(User.email == email).first(): + raise HTTPException(status_code=400, detail="Email already registered") + if self.db.query(User).filter(User.username == username).first(): + raise HTTPException(status_code=400, detail="Username already taken") + + user = User( + email=email, + username=username, + hashed_password=hash_password(payload.password), + full_name=payload.full_name, + role=payload.role, + ) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return user + + # ── Authenticate ── + def authenticate(self, username: str, password: str) -> User | None: + username = username.strip() + user = self.db.query(User).filter( + (User.username == username) | (User.email == username) + ).first() + if not user or not verify_password(password, user.hashed_password): + return None + if not user.is_active: + raise HTTPException(status_code=403, detail="Account deactivated") + return user + + # ── Tokens ── + def _create_token(self, data: dict, expires_delta: timedelta) -> str: + to_encode = data.copy() + to_encode["exp"] = datetime.now(timezone.utc) + expires_delta + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + def create_tokens(self, user: User) -> Token: + access = self._create_token( + {"sub": str(user.id), "role": user.role}, + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), + ) + refresh = self._create_token( + {"sub": str(user.id), "type": "refresh"}, + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS), + ) + return Token(access_token=access, refresh_token=refresh) + + # ── Refresh ── + def refresh(self, refresh_token: str) -> Token: + try: + payload = jwt.decode(refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + if payload.get("type") != "refresh": + raise HTTPException(status_code=401, detail="Invalid token type") + user_id = int(payload["sub"]) + except JWTError: + raise HTTPException(status_code=401, detail="Invalid refresh token") + + user = self.db.query(User).filter(User.id == user_id).first() + if not user or not user.is_active: + raise HTTPException(status_code=401, detail="User not found") + return self.create_tokens(user) \ No newline at end of file diff --git a/app/services/contact_service.py b/app/services/contact_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e5ea1c19f84a38db83a2eefb3113b240ed663bf7 --- /dev/null +++ b/app/services/contact_service.py @@ -0,0 +1,40 @@ +from sqlalchemy.orm import Session, joinedload +from app.models.contact import ContactMessage, ContactReply +from app.schemas.contact import ContactCreate +from app.utils.email import send_reply_email + +def create_contact_message(db: Session, msg: ContactCreate): + new_msg = ContactMessage( + name=msg.name, + email=msg.email, + subject=msg.subject, + message=msg.message + ) + db.add(new_msg) + db.commit() + db.refresh(new_msg) + return new_msg + +def get_all_contact_messages(db: Session): + return db.query(ContactMessage).options(joinedload(ContactMessage.replies)).order_by(ContactMessage.created_at.desc()).all() + +def reply_to_message(db: Session, message_id: int, reply_text: str): + msg = db.query(ContactMessage).filter(ContactMessage.id == message_id).first() + if not msg: + return None + + # Create DB record for the reply + new_reply = ContactReply( + message_id = message_id, + content = reply_text + ) + db.add(new_reply) + db.commit() + db.refresh(new_reply) + + # Attempt to send real email + send_reply_email(msg.email, msg.subject, reply_text) + + # Refresh msg to get the new replies list + db.refresh(msg) + return msg diff --git a/app/services/content_ingestion.py b/app/services/content_ingestion.py new file mode 100644 index 0000000000000000000000000000000000000000..5cb0016f7528fa50e86ed1d808e0f99328c92594 --- /dev/null +++ b/app/services/content_ingestion.py @@ -0,0 +1,54 @@ +""" +Parse uploaded files (PDF, DOCX, PPTX) and chunk them into passages. +""" + +import logging +from typing import List, Dict, Any + +from langchain_text_splitters import RecursiveCharacterTextSplitter + +from app.config import settings +from app.utils.file_parser import parse_pdf, parse_docx, parse_pptx + +logger = logging.getLogger(__name__) + + +class ContentIngestionService: + def __init__(self): + self.splitter = RecursiveCharacterTextSplitter( + chunk_size=settings.CHUNK_SIZE, + chunk_overlap=settings.CHUNK_OVERLAP, + separators=["\n\n", "\n", ". ", " ", ""], + ) + + def parse_and_chunk(self, file_path: str, file_type: str) -> List[Dict[str, Any]]: + """ + Return list of {"text": str, "page": int | None}. + """ + logger.info("Parsing %s (%s)", file_path, file_type) + + if file_type == "pdf": + pages = parse_pdf(file_path) + elif file_type == "docx": + pages = parse_docx(file_path) + elif file_type == "pptx": + pages = parse_pptx(file_path) + else: + raise ValueError(f"Unsupported file type: {file_type}") + + passages: List[Dict[str, Any]] = [] + for page_data in pages: + text = page_data["text"].strip() + if not text: + continue + chunks = self.splitter.split_text(text) + for chunk in chunks: + if len(chunk.strip()) < 20: + continue + passages.append({ + "text": chunk.strip(), + "page": page_data.get("page"), + }) + + logger.info("Created %d passages from %s", len(passages), file_path) + return passages \ No newline at end of file diff --git a/app/services/grading_service.py b/app/services/grading_service.py new file mode 100644 index 0000000000000000000000000000000000000000..d51dbe7381ded383e3e04a55b87e244ded071195 --- /dev/null +++ b/app/services/grading_service.py @@ -0,0 +1,531 @@ +""" +AI Grading Engine — Multi-pass evaluation with NVIDIA Nemotron. + +Grading Pipeline: + 1. MCQ: Exact match (instant, 100% accurate) + 2. Short Answer: Keyword extraction + semantic LLM scoring + 3. Descriptive: Multi-pass rubric-based evaluation + Pass 1: Initial scoring with rubric criteria + Pass 2: Verification pass — checks for over/under scoring + Final: Averaged score with confidence calibration + +This achieves significantly higher grading accuracy than single-pass. +""" + +import json +import logging +from datetime import datetime, timezone +from typing import Dict, List, Optional + +from sqlalchemy.orm import Session + +from app.config import settings +from app.models.submission import ExamSubmission, AnswerResponse +from app.models.question import ExamQuestion +from app.services.rag_pipeline import call_llm + +logger = logging.getLogger(__name__) + +# ═══════════════════════════════════════════════════════════════ +# GRADING PROMPTS — Optimized for Nemotron accuracy +# ═══════════════════════════════════════════════════════════════ + +GRADING_SYSTEM = """You are an expert academic exam grader with years of experience. + +GRADING PRINCIPLES: +1. Be FAIR — grade on substance, not phrasing +2. Be RIGOROUS — partial credit only for partially correct answers +3. Be CONSISTENT — same quality answer always gets same score +4. KEY TERMS matter — correct terminology demonstrates understanding +5. WRONG facts get ZERO credit for that portion +6. You MUST return valid JSON only — no explanation outside JSON""" + +RUBRIC_GRADING_PROMPT = """Grade this student answer using the rubric below. + +═══ QUESTION ═══ +{question} + +═══ MODEL ANSWER ═══ +{correct_answer} + +═══ EVALUATION CRITERIA ═══ +{rubric} + +═══ STUDENT ANSWER ═══ +{student_answer} + +═══ SCORING RULES ═══ +Maximum score: {max_score} +- Full marks: All key concepts present with correct terminology +- 75% marks: Most concepts correct, minor gaps +- 50% marks: Core idea understood, significant gaps +- 25% marks: Some relevant content, major errors +- 0 marks: Completely wrong, irrelevant, or blank + +Evaluate step by step: +1. List which rubric criteria the student met +2. List which criteria are missing or wrong +3. Identify any factual errors +4. Calculate the score + +Return ONLY this JSON: +{{ + "criteria_met": ["criterion 1", "criterion 2"], + "criteria_missed": ["criterion 3"], + "factual_errors": ["error description or empty list"], + "score": , + "is_correct": = 70% of max>, + "feedback": "<2-3 sentences: what was good, what was wrong, how to improve>", + "confidence": +}}""" + +SIMPLE_GRADING_PROMPT = """Grade this student answer by comparing to the model answer. + +═══ QUESTION ═══ +{question} + +═══ MODEL ANSWER ═══ +{correct_answer} + +═══ STUDENT ANSWER ═══ +{student_answer} + +═══ SCORING ═══ +Maximum score: {max_score} +Grade based on factual correctness, completeness, and understanding. + +Return ONLY this JSON: +{{ + "score": , + "is_correct": = 70% of max>, + "feedback": "", + "confidence": +}}""" + +VERIFICATION_PROMPT = """You are a grading auditor. Review this grading result for accuracy. + +═══ QUESTION ═══ +{question} + +═══ MODEL ANSWER ═══ +{correct_answer} + +═══ STUDENT ANSWER ═══ +{student_answer} + +═══ INITIAL GRADE ═══ +Score: {initial_score}/{max_score} +Feedback: {initial_feedback} + +═══ YOUR TASK ═══ +Check if the initial grade is fair and accurate: +1. Is the score too high? (student got credit for wrong things) +2. Is the score too low? (student had correct content that was missed) +3. Is the feedback accurate? + +Return ONLY this JSON: +{{ + "adjusted_score": , + "adjustment_reason": "", + "confidence": +}}""" + +SHORT_ANSWER_PROMPT = """Grade this short answer question. + +═══ QUESTION ═══ +{question} + +═══ MODEL ANSWER ═══ +{correct_answer} + +═══ KEY TERMS (must appear for full credit) ═══ +{key_terms} + +═══ STUDENT ANSWER ═══ +{student_answer} + +═══ SCORING (max {max_score}) ═══ +- All key terms present + correct explanation = full marks +- Most key terms + partial explanation = 70-90% +- Some key terms + vague explanation = 40-60% +- Wrong or irrelevant = 0-20% + +Return ONLY this JSON: +{{ + "key_terms_found": ["term1", "term2"], + "key_terms_missing": ["term3"], + "score": , + "is_correct": = 70% of max>, + "feedback": "", + "confidence": +}}""" + + +class GradingService: + def __init__(self, db: Session): + self.db = db + self.mode = settings.GRADING_MODE + self.confidence_threshold = settings.GRADING_CONFIDENCE_THRESHOLD + + def grade_submission(self, submission: ExamSubmission): + """Grade all answers in a submission.""" + answers = ( + self.db.query(AnswerResponse) + .filter(AnswerResponse.submission_id == submission.id) + .all() + ) + + total_score = 0.0 + total_max = 0.0 + low_confidence_count = 0 + + for answer in answers: + question = ( + self.db.query(ExamQuestion) + .filter(ExamQuestion.id == answer.question_id) + .first() + ) + if not question: + continue + + answer.max_score = question.marks + total_max += question.marks + + if not answer.student_answer or not answer.student_answer.strip(): + answer.score = 0.0 + answer.is_correct = False + answer.ai_feedback = "No answer provided." + answer.confidence_score = 1.0 + continue + + # ── Grade by type ── + if question.question_type == "mcq": + self._grade_mcq(answer, question) + elif question.question_type == "short_answer": + self._grade_short_answer(answer, question) + else: + self._grade_descriptive(answer, question) + + total_score += answer.score + + if answer.confidence_score is not None and answer.confidence_score < self.confidence_threshold: + low_confidence_count += 1 + + # ── Update submission ── + submission.total_score = round(total_score, 2) + submission.max_score = round(total_max, 2) + submission.percentage = round((total_score / total_max * 100), 2) if total_max > 0 else 0 + exam = submission.exam + passing_pct = (exam.passing_marks / exam.total_marks * 100) if exam and exam.total_marks else 40 + submission.is_passed = submission.percentage >= passing_pct + submission.status = "graded" + submission.graded_at = datetime.now(timezone.utc) + + self.db.commit() + + logger.info( + "Graded submission %d: %.1f/%.1f (%.1f%%) — %d low-confidence answers", + submission.id, total_score, total_max, + submission.percentage or 0, low_confidence_count, + ) + + # ═══════════════════════════════════════════════════════════ + # MCQ — Exact match (100% accurate, no LLM needed) + # ═══════════════════════════════════════════════════════════ + + def _grade_mcq(self, answer: AnswerResponse, question: ExamQuestion): + student = answer.student_answer.strip().upper() + correct = question.correct_answer.strip().upper() + + is_correct = student == correct + answer.is_correct = is_correct + answer.score = question.marks if is_correct else 0.0 + answer.confidence_score = 1.0 + + if is_correct: + answer.ai_feedback = "Correct!" + else: + answer.ai_feedback = f"Incorrect. The correct answer is {correct}." + + if question.explanation: + answer.ai_feedback += f" {question.explanation}" + + # ═══════════════════════════════════════════════════════════ + # SHORT ANSWER — Key term matching + LLM evaluation + # ═══════════════════════════════════════════════════════════ + + def _grade_short_answer(self, answer: AnswerResponse, question: ExamQuestion): + # Extract key terms from explanation if available + key_terms = self._extract_key_terms(question) + + try: + prompt = SHORT_ANSWER_PROMPT.format( + question=question.question_text, + correct_answer=question.correct_answer, + key_terms=", ".join(key_terms) if key_terms else "Not specified — compare to model answer", + student_answer=answer.student_answer, + max_score=question.marks, + ) + raw = call_llm(prompt, GRADING_SYSTEM, temperature=0.1) + result = self._parse_grade(raw, question.marks) + + answer.score = result["score"] + answer.is_correct = result["is_correct"] + answer.confidence_score = result["confidence"] + + # Build detailed feedback + feedback_parts = [result["feedback"]] + if result.get("key_terms_found"): + feedback_parts.append(f"Key terms found: {', '.join(result['key_terms_found'])}") + if result.get("key_terms_missing"): + feedback_parts.append(f"Missing: {', '.join(result['key_terms_missing'])}") + answer.ai_feedback = " | ".join(feedback_parts) + + except Exception as e: + logger.error("Short answer grading failed for answer %d: %s", answer.id, e) + self._fallback_grade(answer, question) + + # ═══════════════════════════════════════════════════════════ + # DESCRIPTIVE — Multi-pass rubric evaluation + # ═══════════════════════════════════════════════════════════ + + def _grade_descriptive(self, answer: AnswerResponse, question: ExamQuestion): + if self.mode == "multi_pass": + self._grade_descriptive_multi_pass(answer, question) + else: + self._grade_descriptive_single(answer, question) + + def _grade_descriptive_single(self, answer: AnswerResponse, question: ExamQuestion): + """Single-pass grading — faster but less accurate.""" + try: + rubric = self._extract_rubric(question) + + if rubric and settings.ENABLE_RUBRIC_GRADING: + prompt = RUBRIC_GRADING_PROMPT.format( + question=question.question_text, + correct_answer=question.correct_answer, + rubric=rubric, + student_answer=answer.student_answer, + max_score=question.marks, + ) + else: + prompt = SIMPLE_GRADING_PROMPT.format( + question=question.question_text, + correct_answer=question.correct_answer, + student_answer=answer.student_answer, + max_score=question.marks, + ) + + raw = call_llm(prompt, GRADING_SYSTEM, temperature=0.1) + result = self._parse_grade(raw, question.marks) + + answer.score = result["score"] + answer.is_correct = result["is_correct"] + answer.ai_feedback = result["feedback"] + answer.confidence_score = result["confidence"] + + except Exception as e: + logger.error("Descriptive grading failed for answer %d: %s", answer.id, e) + self._fallback_grade(answer, question) + + def _grade_descriptive_multi_pass(self, answer: AnswerResponse, question: ExamQuestion): + """ + Multi-pass grading for maximum accuracy: + Pass 1: Initial rubric-based scoring + Pass 2: Verification — check for over/under scoring + Final: Weighted average with confidence calibration + """ + try: + rubric = self._extract_rubric(question) + + # ── PASS 1: Initial grading ── + if rubric and settings.ENABLE_RUBRIC_GRADING: + prompt1 = RUBRIC_GRADING_PROMPT.format( + question=question.question_text, + correct_answer=question.correct_answer, + rubric=rubric, + student_answer=answer.student_answer, + max_score=question.marks, + ) + else: + prompt1 = SIMPLE_GRADING_PROMPT.format( + question=question.question_text, + correct_answer=question.correct_answer, + student_answer=answer.student_answer, + max_score=question.marks, + ) + + raw1 = call_llm(prompt1, GRADING_SYSTEM, temperature=0.1) + result1 = self._parse_grade(raw1, question.marks) + + # ── PASS 2: Verification ── + prompt2 = VERIFICATION_PROMPT.format( + question=question.question_text, + correct_answer=question.correct_answer, + student_answer=answer.student_answer, + initial_score=result1["score"], + max_score=question.marks, + initial_feedback=result1["feedback"], + ) + + raw2 = call_llm(prompt2, GRADING_SYSTEM, temperature=0.1) + result2 = self._parse_verification(raw2, question.marks) + + # ── COMBINE: Weighted average ── + pass1_score = result1["score"] + pass2_score = result2["adjusted_score"] + pass1_conf = result1["confidence"] + pass2_conf = result2["confidence"] + + # Weight by confidence + total_conf = pass1_conf + pass2_conf + if total_conf > 0: + final_score = (pass1_score * pass1_conf + pass2_score * pass2_conf) / total_conf + else: + final_score = (pass1_score + pass2_score) / 2 + + final_score = round(min(final_score, question.marks), 2) + final_confidence = round((pass1_conf + pass2_conf) / 2, 3) + + answer.score = final_score + answer.is_correct = final_score >= (question.marks * 0.7) + answer.confidence_score = final_confidence + + # Build comprehensive feedback + feedback_parts = [result1["feedback"]] + if abs(pass1_score - pass2_score) > 0.5: + feedback_parts.append( + f"[Verification adjusted score from {pass1_score} to {pass2_score}: " + f"{result2.get('adjustment_reason', 'refinement')}]" + ) + if final_confidence < self.confidence_threshold: + feedback_parts.append("[⚠ Low confidence — instructor review recommended]") + + answer.ai_feedback = " ".join(feedback_parts) + + logger.debug( + "Multi-pass grade: P1=%.2f (conf=%.2f) P2=%.2f (conf=%.2f) → Final=%.2f", + pass1_score, pass1_conf, pass2_score, pass2_conf, final_score, + ) + + except Exception as e: + logger.error("Multi-pass grading failed for answer %d: %s", answer.id, e) + # Try single pass as fallback + try: + self._grade_descriptive_single(answer, question) + except Exception: + self._fallback_grade(answer, question) + + # ═══════════════════════════════════════════════════════════ + # HELPERS + # ═══════════════════════════════════════════════════════════ + + def _extract_rubric(self, question: ExamQuestion) -> str: + """Extract rubric criteria from question explanation.""" + if not question.explanation: + return "" + explanation = question.explanation + rubric_parts = [] + if "Rubric:" in explanation: + rubric_section = explanation.split("Rubric:")[1].strip() + rubric_parts.append(rubric_section) + elif "Key terms:" in explanation: + terms_section = explanation.split("Key terms:")[1].strip() + rubric_parts.append(f"Must include these key terms: {terms_section}") + if not rubric_parts: + rubric_parts.append(f"Compare against model answer. Explanation: {explanation}") + return "\n".join(rubric_parts) + + def _extract_key_terms(self, question: ExamQuestion) -> List[str]: + """Extract key terms from explanation.""" + if not question.explanation: + return [] + if "Key terms:" in question.explanation: + terms_str = question.explanation.split("Key terms:")[1].strip() + return [t.strip() for t in terms_str.split(",") if t.strip()] + return [] + + def _fallback_grade(self, answer: AnswerResponse, question: ExamQuestion): + """Keyword overlap scoring when LLM is unavailable.""" + student_words = set(answer.student_answer.lower().split()) + correct_words = set(question.correct_answer.lower().split()) + # Remove common stop words + stop_words = {"the", "a", "an", "is", "are", "was", "were", "in", "on", "at", + "to", "for", "of", "and", "or", "but", "it", "this", "that", "with"} + student_words -= stop_words + correct_words -= stop_words + + if not correct_words: + answer.score = 0.0 + answer.is_correct = False + answer.ai_feedback = "Could not auto-grade. Manual review required." + answer.confidence_score = 0.0 + return + + overlap = len(student_words & correct_words) / len(correct_words) + answer.score = round(overlap * question.marks, 2) + answer.is_correct = overlap >= 0.7 + answer.confidence_score = 0.2 + answer.ai_feedback = ( + f"Fallback scoring by keyword overlap ({overlap:.0%}). " + f"Matched: {', '.join(student_words & correct_words) or 'none'}. " + f"⚠ Manual review strongly recommended." + ) + + @staticmethod + def _parse_grade(text: str, max_score: float) -> dict: + """Parse grading JSON from LLM output.""" + text = text.strip() + if text.startswith("```"): + lines = text.split("\n") + lines = [l for l in lines if not l.strip().startswith("```")] + text = "\n".join(lines).strip() + try: + start = text.find("{") + end = text.rfind("}") + if start != -1 and end != -1: + data = json.loads(text[start: end + 1]) + return { + "score": min(float(data.get("score", 0)), max_score), + "is_correct": bool(data.get("is_correct", False)), + "feedback": str(data.get("feedback", "")), + "confidence": min(float(data.get("confidence", 0.5)), 1.0), + "key_terms_found": data.get("key_terms_found", []), + "key_terms_missing": data.get("key_terms_missing", []), + "criteria_met": data.get("criteria_met", []), + "criteria_missed": data.get("criteria_missed", []), + } + except (json.JSONDecodeError, ValueError, TypeError): + pass + return { + "score": 0.0, + "is_correct": False, + "feedback": "Grading response could not be parsed. Manual review needed.", + "confidence": 0.0, + } + + @staticmethod + def _parse_verification(text: str, max_score: float) -> dict: + """Parse verification pass JSON.""" + text = text.strip() + if text.startswith("```"): + lines = text.split("\n") + lines = [l for l in lines if not l.strip().startswith("```")] + text = "\n".join(lines).strip() + try: + start = text.find("{") + end = text.rfind("}") + if start != -1 and end != -1: + data = json.loads(text[start: end + 1]) + return { + "adjusted_score": min(float(data.get("adjusted_score", 0)), max_score), + "adjustment_reason": str(data.get("adjustment_reason", "")), + "confidence": min(float(data.get("confidence", 0.5)), 1.0), + } + except (json.JSONDecodeError, ValueError, TypeError): + pass + return { + "adjusted_score": 0.0, + "adjustment_reason": "Could not parse verification", + "confidence": 0.0, + } \ No newline at end of file diff --git a/app/services/nvidia_embedder.py b/app/services/nvidia_embedder.py new file mode 100644 index 0000000000000000000000000000000000000000..159cbf96363ddebf8eb10c196106ae69e71cfdc8 --- /dev/null +++ b/app/services/nvidia_embedder.py @@ -0,0 +1,284 @@ +""" +NVIDIA NIM clients: Embedder + Reranker + LLM. +""" + +import logging +import time +import requests +from typing import List, Optional, Dict, Any + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class NvidiaLLM: + """ + NVIDIA NIM LLM client. + Model: nvidia/nemotron-3-nano-30b-a3b + """ + + def __init__(self): + self.api_key = settings.NVIDIA_API_KEY + self.base_url = settings.NVIDIA_BASE_URL.rstrip("/") + self.model = settings.NVIDIA_LLM_MODEL + logger.info("NvidiaLLM initialized: %s", self.model) + + def chat( + self, + prompt: str, + system_prompt: str = "", + temperature: float | None = None, + max_tokens: int | None = None, + json_mode: bool = False, + ) -> str: + from openai import OpenAI + + client = OpenAI( + api_key=self.api_key, + base_url=self.base_url, + ) + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + kwargs: Dict[str, Any] = { + "model": self.model, + "messages": messages, + "temperature": temperature if temperature is not None else settings.LLM_TEMPERATURE, + "max_tokens": max_tokens or settings.LLM_MAX_TOKENS, + } + + if json_mode: + kwargs["response_format"] = {"type": "json_object"} + + start = time.perf_counter() + try: + response = client.chat.completions.create(**kwargs) + elapsed = time.perf_counter() - start + result = response.choices[0].message.content or "" + logger.info( + "NVIDIA LLM: %d chars in %.2fs (model=%s, tokens=%d)", + len(result), elapsed, self.model, + max_tokens or settings.LLM_MAX_TOKENS, + ) + return result + except Exception as e: + elapsed = time.perf_counter() - start + logger.error("NVIDIA LLM failed after %.2fs: %s", elapsed, str(e)) + raise + + def chat_with_retry(self, prompt: str, system_prompt: str = "", retries: int = 2, **kwargs) -> str: + last_error = None + for attempt in range(retries + 1): + try: + return self.chat(prompt, system_prompt, **kwargs) + except Exception as e: + last_error = e + if attempt < retries: + wait = 2 ** attempt + logger.warning("NVIDIA LLM attempt %d failed, retry in %ds: %s", attempt + 1, wait, str(e)) + time.sleep(wait) + raise last_error + + +class NvidiaEmbedder: + """ + NVIDIA NIM embedding client. + Model: nvidia/llama-3.2-nv-embedqa-1b-v2 + """ + + def __init__(self): + self.api_key = settings.NVIDIA_API_KEY + self.base_url = settings.NVIDIA_BASE_URL.rstrip("/") + self.model = settings.NVIDIA_EMBED_MODEL + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + }) + self._dimension: Optional[int] = None + logger.info("NvidiaEmbedder initialized: %s", self.model) + + @property + def dimension(self) -> int: + if self._dimension is None: + test = self.embed(["dimension test"]) + self._dimension = len(test[0]) + logger.info("Embedding dimension: %d", self._dimension) + return self._dimension + + def embed(self, texts: List[str], input_type: str = "passage") -> List[List[float]]: + if not texts: + return [] + + url = f"{self.base_url}/embeddings" + all_embeddings = [] + batch_size = 50 + + for i in range(0, len(texts), batch_size): + batch = texts[i: i + batch_size] + batch = [t[:8000] if len(t) > 8000 else t for t in batch] + + payload = { + "model": self.model, + "input": batch, + "input_type": input_type, + "encoding_format": "float", + } + + try: + response = self.session.post(url, json=payload, timeout=120) + response.raise_for_status() + data = response.json() + sorted_data = sorted(data["data"], key=lambda x: x["index"]) + all_embeddings.extend([item["embedding"] for item in sorted_data]) + except requests.exceptions.HTTPError as e: + logger.error("NVIDIA Embed API: %s — %s", e.response.status_code, e.response.text[:500]) + raise RuntimeError(f"NVIDIA Embed API failed: {e.response.status_code}") from e + except Exception as e: + logger.error("NVIDIA Embed failed: %s", str(e)) + raise + + return all_embeddings + + def embed_query(self, text: str) -> List[float]: + return self.embed([text], input_type="query")[0] + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + return self.embed(texts, input_type="passage") + + +class NvidiaReranker: + """ + NVIDIA NIM reranking client. + + NVIDIA rerank API uses model-specific URLs: + https://ai.api.nvidia.com/v1/retrieval/{model_name}/reranking + + NOT the generic /v1/ranking endpoint. + """ + + # Map model names to their correct API endpoint paths + RERANK_ENDPOINTS = { + "nvidia/llama-nemotron-rerank-1b-v2": "https://ai.api.nvidia.com/v1/retrieval/nvidia/llama-nemotron-rerank-1b-v2/reranking", + "nvidia/llama-3.2-nv-rerankqa-1b-v2": "https://ai.api.nvidia.com/v1/retrieval/nvidia/llama-3.2-nv-rerankqa-1b-v2/reranking", + "nvidia/llama-3.2-nemoretriever-500m-rerank-v2": "https://ai.api.nvidia.com/v1/retrieval/nvidia/llama-3.2-nemoretriever-500m-rerank-v2/reranking", + "nvidia/rerank-qa-mistral-4b": "https://ai.api.nvidia.com/v1/retrieval/nvidia/rerank-qa-mistral-4b/reranking", + "nvidia/nv-rerankqa-mistral-4b-v3": "https://ai.api.nvidia.com/v1/retrieval/nvidia/nv-rerankqa-mistral-4b-v3/reranking", + } + + def __init__(self): + self.api_key = settings.NVIDIA_API_KEY + self.model = settings.NVIDIA_RERANK_MODEL + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + }) + + # Resolve the correct endpoint URL + if self.model in self.RERANK_ENDPOINTS: + self.url = self.RERANK_ENDPOINTS[self.model] + else: + # Build URL from model name + self.url = f"https://ai.api.nvidia.com/v1/retrieval/{self.model}/reranking" + + logger.info("NvidiaReranker initialized: %s → %s", self.model, self.url) + + def rerank( + self, + query: str, + passages: List[dict], + top_k: int = 5, + ) -> List[dict]: + """ + Rerank passages by relevance to query. + """ + if not passages or not query: + return passages[:top_k] + + # Build the request payload + # NVIDIA rerank API expects: query.text + passages[].text + documents = [] + for p in passages: + text = p.get("text", "") + if text: + documents.append(text[:4000]) + + if not documents: + return passages[:top_k] + + payload = { + "model": self.model, + "query": {"text": query}, + "passages": [{"text": doc} for doc in documents], + } + + try: + response = self.session.post(self.url, json=payload, timeout=120) + + # If model-specific URL fails, try alternative endpoint formats + if response.status_code == 404: + logger.warning("Rerank endpoint 404, trying alternative URL format...") + alt_url = f"{settings.NVIDIA_BASE_URL.rstrip('/')}/ranking" + payload_alt = { + "model": self.model, + "query": {"text": query}, + "passages": [{"text": doc} for doc in documents], + "top_n": min(top_k, len(documents)), + } + response = self.session.post(alt_url, json=payload_alt, timeout=120) + + if response.status_code == 404: + logger.warning("Rerank endpoint 404 on both URLs, trying OpenAI-compatible format...") + # Some NVIDIA models use a different payload format + alt_url2 = f"https://ai.api.nvidia.com/v1/retrieval/{self.model}/reranking" + payload_v2 = { + "model": self.model, + "query": {"text": query}, + "passages": [{"text": doc} for doc in documents], + } + response = self.session.post(alt_url2, json=payload_v2, timeout=120) + + response.raise_for_status() + data = response.json() + + # Parse response — handle different response formats + rankings = data.get("rankings", []) + + reranked = [] + for rank in rankings: + idx = rank.get("index", 0) + if idx < len(passages): + passage = passages[idx].copy() + passage["rerank_score"] = rank.get("logit", rank.get("score", 0)) + reranked.append(passage) + + # Sort by score descending and take top_k + reranked.sort(key=lambda x: x.get("rerank_score", 0), reverse=True) + reranked = reranked[:top_k] + + if reranked: + logger.info( + "Reranked %d → %d (scores: %.3f to %.3f)", + len(passages), len(reranked), + reranked[0].get("rerank_score", 0), + reranked[-1].get("rerank_score", 0), + ) + return reranked + + except requests.exceptions.HTTPError as e: + logger.warning( + "NVIDIA Rerank API failed (%s): %s — falling back to embedding-only results", + e.response.status_code, + e.response.text[:300], + ) + return passages[:top_k] + except Exception as e: + logger.warning("NVIDIA Rerank failed: %s — falling back to embedding-only results", str(e)) + return passages[:top_k] \ No newline at end of file diff --git a/app/services/question_generator.py b/app/services/question_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..eefc4946b5279d916b3e3281325bf9e8a109280a --- /dev/null +++ b/app/services/question_generator.py @@ -0,0 +1,273 @@ +""" +RAG + LLM question generation with robust JSON parsing. +""" + +import json +import logging +import re +from typing import List, Optional + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.models.question import ExamQuestion +from app.services.rag_pipeline import RAGPipeline + +logger = logging.getLogger(__name__) + +SYSTEM_PROMPT = """You are an expert exam question generator for academic assessments. + +STRICT RULES: +1. Use ONLY the provided course context. +2. Every question must be answerable from the context. +3. Return ONLY a valid JSON array — no markdown fences, no extra text. +4. Keep model answers CONCISE — max 3 sentences for short answer, max 5 sentences for descriptive. +5. Each question must have ONE clear correct answer.""" + +MCQ_PROMPT = """Generate exactly {num} MCQ questions from the context. +Difficulty: {difficulty} +{topic_line} + +Each question: 4 options (A,B,C,D), one correct answer, brief explanation. + +Return JSON array: +[{{"question_text":"...","options":{{"A":"...","B":"...","C":"...","D":"..."}},"correct_answer":"A","explanation":"...","difficulty":"{difficulty}"}}]""" + +SHORT_ANSWER_PROMPT = """Generate exactly {num} short-answer questions from the context. +Difficulty: {difficulty} +{topic_line} + +Keep model answers to 1-2 sentences maximum. + +Return JSON array: +[{{"question_text":"...","correct_answer":"1-2 sentence answer","explanation":"Why this is correct","difficulty":"{difficulty}"}}]""" + +DESCRIPTIVE_PROMPT = """Generate exactly {num} essay questions from the context. +Difficulty: {difficulty} +{topic_line} + +Keep model answers to 3-5 sentences maximum. Be concise. + +Return JSON array: +[{{"question_text":"...","correct_answer":"3-5 sentence model answer","explanation":"Key evaluation points","difficulty":"{difficulty}"}}]""" + + +class QuestionGeneratorService: + def __init__(self, db: Session): + self.db = db + self.rag = RAGPipeline() + + def generate( + self, + course_id: int, + exam_id: int, + num_questions: int = 5, + question_type: str = "mcq", + difficulty: str = "medium", + topic: Optional[str] = None, + ) -> List[ExamQuestion]: + + topic_line = f"Focus on: {topic}" if topic else "" + search_query = topic or "key concepts important topics" + + passages = self.rag.retrieve_context(course_id, search_query, top_k=10) + if not passages: + raise ValueError("No indexed content found. Upload and index course files first.") + + if question_type == "mixed": + mcq_n = max(1, num_questions // 3) + short_n = max(1, num_questions // 3) + desc_n = num_questions - mcq_n - short_n + questions = [] + if mcq_n > 0: + questions += self._gen_type(passages, "mcq", mcq_n, difficulty, topic_line, exam_id) + if short_n > 0: + questions += self._gen_type(passages, "short_answer", short_n, difficulty, topic_line, exam_id) + if desc_n > 0: + questions += self._gen_type(passages, "descriptive", desc_n, difficulty, topic_line, exam_id) + return questions + else: + return self._gen_type(passages, question_type, num_questions, difficulty, topic_line, exam_id) + + def _gen_type(self, passages, qtype, num, difficulty, topic_line, exam_id) -> List[ExamQuestion]: + templates = { + "mcq": MCQ_PROMPT, + "short_answer": SHORT_ANSWER_PROMPT, + "descriptive": DESCRIPTIVE_PROMPT, + } + template = templates.get(qtype, MCQ_PROMPT) + user_prompt = template.format(num=num, difficulty=difficulty, topic_line=topic_line) + + # Use higher max_tokens for descriptive to avoid truncation + token_limits = { + "mcq": 4096, + "short_answer": 4096, + "descriptive": 8192, + } + + raw = self.rag.generate_with_context( + passages, user_prompt, SYSTEM_PROMPT, + temperature=0.3, + max_tokens=token_limits.get(qtype, 4096), + ) + + questions_data = self._parse_json(raw) + if not questions_data: + # Retry once with explicit JSON instruction + retry_prompt = user_prompt + "\n\nIMPORTANT: Return ONLY the JSON array. No markdown. No ```json. Just the raw [ ... ] array." + raw = self.rag.generate_with_context( + passages, retry_prompt, SYSTEM_PROMPT, + temperature=0.2, + max_tokens=token_limits.get(qtype, 4096), + ) + questions_data = self._parse_json(raw) + + if not questions_data: + raise ValueError(f"Failed to parse LLM response for {qtype} questions. The AI response was not valid JSON.") + + max_idx = ( + self.db.query(func.max(ExamQuestion.order_index)) + .filter(ExamQuestion.exam_id == exam_id) + .scalar() + ) or 0 + + marks_map = {"mcq": 1.0, "short_answer": 3.0, "descriptive": 5.0} + created: List[ExamQuestion] = [] + + for i, qd in enumerate(questions_data[:num]): + if not isinstance(qd, dict): + continue + question_text = qd.get("question_text", "").strip() + correct_answer = qd.get("correct_answer", "").strip() + if not question_text or not correct_answer: + continue + + q = ExamQuestion( + exam_id=exam_id, + question_text=question_text, + question_type=qtype, + options=qd.get("options"), + correct_answer=correct_answer, + marks=marks_map.get(qtype, 1.0), + explanation=qd.get("explanation", ""), + difficulty=qd.get("difficulty", difficulty), + order_index=max_idx + i + 1, + ) + self.db.add(q) + created.append(q) + + self.db.commit() + for q in created: + self.db.refresh(q) + + logger.info("Generated %d %s questions for exam %d", len(created), qtype, exam_id) + return created + + @staticmethod + def _parse_json(text: str) -> list: + """ + Robust JSON extraction from LLM output. + Handles: markdown fences, truncated JSON, mixed text. + """ + if not text or not text.strip(): + return [] + + text = text.strip() + + # Step 1: Remove markdown code fences + text = re.sub(r'^```(?:json)?\s*\n?', '', text, flags=re.MULTILINE) + text = re.sub(r'\n?```\s*$', '', text, flags=re.MULTILINE) + text = text.strip() + + # Step 2: Try direct parse + try: + data = json.loads(text) + if isinstance(data, list): + return data + if isinstance(data, dict): + return [data] + except json.JSONDecodeError: + pass + + # Step 3: Find JSON array in text + start = text.find("[") + end = text.rfind("]") + if start != -1 and end != -1 and end > start: + json_str = text[start: end + 1] + try: + data = json.loads(json_str) + if isinstance(data, list): + return data + except json.JSONDecodeError: + pass + + # Step 4: Try to fix truncated JSON (response cut off mid-object) + if start != -1: + json_str = text[start:] + + # If array is not closed, try to close it + if "]" not in json_str: + # Find the last complete object (ends with }) + last_brace = json_str.rfind("}") + if last_brace != -1: + json_str = json_str[:last_brace + 1] + "]" + try: + data = json.loads(json_str) + if isinstance(data, list): + logger.warning("Recovered %d items from truncated JSON", len(data)) + return data + except json.JSONDecodeError: + pass + + # Try removing the last incomplete object + # Find all complete objects by splitting on },{ + try: + # Remove outer brackets + inner = json_str.strip() + if inner.startswith("["): + inner = inner[1:] + if inner.endswith("]"): + inner = inner[:-1] + + # Split into potential objects + objects = [] + depth = 0 + current = "" + for char in inner: + current += char + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + # Try parsing this object + obj_str = current.strip().strip(",").strip() + try: + obj = json.loads(obj_str) + objects.append(obj) + except json.JSONDecodeError: + pass + current = "" + + if objects: + logger.warning("Recovered %d items by parsing individual objects", len(objects)) + return objects + except Exception: + pass + + # Step 5: Try to find individual JSON objects + objects = [] + for match in re.finditer(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', text): + try: + obj = json.loads(match.group()) + if "question_text" in obj: + objects.append(obj) + except json.JSONDecodeError: + continue + + if objects: + logger.warning("Recovered %d questions by regex extraction", len(objects)) + return objects + + logger.error("JSON parse completely failed. Response preview: %s", text[:500]) + return [] \ No newline at end of file diff --git a/app/services/rag_pipeline.py b/app/services/rag_pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..c9df4c5e1c2c664b9f57f2e3ce1136d6b40ac46a --- /dev/null +++ b/app/services/rag_pipeline.py @@ -0,0 +1,157 @@ +""" +RAG pipeline — with max_tokens passthrough for long responses. +""" + +import logging +from typing import List, Dict, Any + +from app.config import settings +from app.services.vector_store import VectorStoreService + +logger = logging.getLogger(__name__) + + +def _get_llm_response( + prompt: str, + system_prompt: str = "", + temperature: float | None = None, + max_tokens: int | None = None, + json_mode: bool = False, + provider_override: str | None = None, +) -> str: + provider = (provider_override or settings.LLM_PROVIDER).lower() + + if provider == "nvidia": + try: + from app.services.nvidia_embedder import NvidiaLLM + llm = NvidiaLLM() + return llm.chat( + prompt=prompt, + system_prompt=system_prompt, + temperature=temperature, + max_tokens=max_tokens, + json_mode=json_mode, + ) + except Exception as e: + logger.warning("NVIDIA LLM failed, trying fallback: %s", str(e)) + fallback = settings.FALLBACK_LLM_PROVIDER.lower() + if fallback and fallback != "nvidia": + return _get_llm_response( + prompt, system_prompt, temperature, max_tokens, + json_mode=False, provider_override=fallback, + ) + raise + + elif provider == "openai": + from openai import OpenAI + client = OpenAI(api_key=settings.OPENAI_API_KEY) + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + kwargs = { + "model": settings.OPENAI_MODEL, + "messages": messages, + "temperature": temperature if temperature is not None else settings.LLM_TEMPERATURE, + "max_tokens": max_tokens or settings.LLM_MAX_TOKENS, + } + response = client.chat.completions.create(**kwargs) + return response.choices[0].message.content or "" + + elif provider == "gemini": + import google.generativeai as genai + genai.configure(api_key=settings.GOOGLE_API_KEY) + model = genai.GenerativeModel(settings.GEMINI_MODEL) + full_prompt = f"{system_prompt}\n\n{prompt}" if system_prompt else prompt + response = model.generate_content(full_prompt) + return response.text or "" + + else: + raise ValueError(f"Unsupported LLM provider: {provider}") + + +class RAGPipeline: + def __init__(self): + self.vs = VectorStoreService() + + def retrieve_context( + self, + course_id: int, + query: str, + top_k: int = 8, + use_reranker: bool | None = None, + ) -> List[Dict[str, Any]]: + collection_name = f"course_{course_id}" + should_rerank = use_reranker if use_reranker is not None else settings.USE_RERANKER + + if should_rerank: + results = self.vs.search_with_rerank( + collection_name, query, + initial_top_k=settings.RETRIEVAL_TOP_K, + final_top_k=settings.RERANK_TOP_K, + ) + else: + results = self.vs.search(collection_name, query, top_k=top_k) + + logger.info("Retrieved %d passages for course %d (reranked=%s)", len(results), course_id, should_rerank) + return results + + def generate_with_context( + self, + context_passages: List[Dict[str, Any]], + user_prompt: str, + system_prompt: str = "", + temperature: float | None = None, + json_mode: bool = False, + max_tokens: int | None = None, + ) -> str: + context_parts = [] + for i, p in enumerate(context_passages): + text = p.get("text", "") + if not text: + continue + score_info = "" + if "rerank_score" in p: + score_info = f" [relevance: {p['rerank_score']:.3f}]" + context_parts.append(f"[Passage {i + 1}{score_info}]\n{text}") + + context_text = "\n\n---\n\n".join(context_parts) + full_prompt = ( + f"### CONTEXT (course material — use ONLY this):\n" + f"{context_text}\n\n" + f"### INSTRUCTION:\n{user_prompt}" + ) + return _get_llm_response( + full_prompt, system_prompt, + temperature=temperature, + max_tokens=max_tokens, + json_mode=json_mode, + ) + + def query( + self, + course_id: int, + user_prompt: str, + system_prompt: str = "", + top_k: int = 8, + ) -> str: + passages = self.retrieve_context(course_id, user_prompt, top_k) + if not passages: + logger.warning("No passages found for course %d", course_id) + return _get_llm_response(user_prompt, system_prompt) + return self.generate_with_context(passages, user_prompt, system_prompt) + + +def call_llm( + prompt: str, + system_prompt: str = "", + temperature: float | None = None, + json_mode: bool = False, + max_tokens: int | None = None, +) -> str: + return _get_llm_response( + prompt, system_prompt, + temperature=temperature, + max_tokens=max_tokens, + json_mode=json_mode, + ) \ No newline at end of file diff --git a/app/services/vector_store.py b/app/services/vector_store.py new file mode 100644 index 0000000000000000000000000000000000000000..b5b8c3b8dc8e14d60884211d3732e02d373a3b0f --- /dev/null +++ b/app/services/vector_store.py @@ -0,0 +1,188 @@ +""" +ChromaDB vector store — NVIDIA embeddings + reranking integration. +""" + +import logging +from typing import List, Dict, Any, Optional + +import chromadb +from chromadb.config import Settings as ChromaSettings + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class VectorStoreService: + _client: chromadb.ClientAPI | None = None + _embedder = None + _provider: str | None = None + + def __init__(self): + if VectorStoreService._client is None: + VectorStoreService._client = chromadb.PersistentClient( + path=settings.VECTOR_STORE_DIR, + settings=ChromaSettings(anonymized_telemetry=False), + ) + + if VectorStoreService._embedder is None: + provider = settings.EMBEDDING_PROVIDER.lower() + VectorStoreService._provider = provider + + if provider == "nvidia_api": + from app.services.nvidia_embedder import NvidiaEmbedder + VectorStoreService._embedder = NvidiaEmbedder() + logger.info("Embedding: NVIDIA API — %s", settings.NVIDIA_EMBED_MODEL) + + elif provider == "nvidia_local": + from sentence_transformers import SentenceTransformer + model_name = settings.NVIDIA_EMBED_MODEL.replace("nvidia/", "") + VectorStoreService._embedder = SentenceTransformer(model_name, trust_remote_code=True) + logger.info("Embedding: NVIDIA local — %s", model_name) + + else: + from sentence_transformers import SentenceTransformer + VectorStoreService._embedder = SentenceTransformer(settings.EMBEDDING_MODEL) + logger.info("Embedding: local — %s", settings.EMBEDDING_MODEL) + + self.client = VectorStoreService._client + self.embedder = VectorStoreService._embedder + self.provider = VectorStoreService._provider + + def _get_or_create_collection(self, name: str): + return self.client.get_or_create_collection( + name=name, + metadata={"hnsw:space": "cosine"}, + ) + + def _embed(self, texts: List[str], input_type: str = "passage") -> List[List[float]]: + if self.provider == "nvidia_api": + return self.embedder.embed(texts, input_type=input_type) + elif self.provider == "nvidia_local": + embeddings = self.embedder.encode(texts, show_progress_bar=False) + return embeddings.tolist() + else: + embeddings = self.embedder.encode(texts, show_progress_bar=False) + return embeddings.tolist() + + def _embed_query(self, text: str) -> List[float]: + if self.provider == "nvidia_api": + return self.embedder.embed_query(text) + return self._embed([text], input_type="query")[0] + + def _embed_documents(self, texts: List[str]) -> List[List[float]]: + return self._embed(texts, input_type="passage") + + def add_passage( + self, + collection_name: str, + passage_id: str, + text: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> str: + collection = self._get_or_create_collection(collection_name) + embedding = self._embed_documents([text])[0] + safe_meta = {k: v for k, v in (metadata or {}).items() if v is not None} + collection.add( + ids=[passage_id], + embeddings=[embedding], + documents=[text], + metadatas=[safe_meta], + ) + return passage_id + + def add_passages_batch( + self, + collection_name: str, + passage_ids: List[str], + texts: List[str], + metadatas: Optional[List[Dict]] = None, + ): + collection = self._get_or_create_collection(collection_name) + embeddings = self._embed_documents(texts) + collection.add( + ids=passage_ids, + embeddings=embeddings, + documents=texts, + metadatas=metadatas or [{}] * len(texts), + ) + logger.info("Batch added %d passages to %s", len(texts), collection_name) + + def search( + self, + collection_name: str, + query: str, + top_k: int = 5, + where: Optional[Dict] = None, + ) -> List[Dict[str, Any]]: + collection = self._get_or_create_collection(collection_name) + if collection.count() == 0: + return [] + + query_embedding = self._embed_query(query) + kwargs: Dict[str, Any] = { + "query_embeddings": [query_embedding], + "n_results": min(top_k, collection.count()), + } + if where: + kwargs["where"] = where + + results = collection.query(**kwargs) + output = [] + if results and results["documents"]: + for i, doc in enumerate(results["documents"][0]): + output.append({ + "id": results["ids"][0][i] if results["ids"] else None, + "text": doc, + "distance": results["distances"][0][i] if results["distances"] else None, + "metadata": results["metadatas"][0][i] if results["metadatas"] else {}, + }) + return output + + def search_with_rerank( + self, + collection_name: str, + query: str, + initial_top_k: int = 25, + final_top_k: int = 6, + ) -> List[Dict[str, Any]]: + candidates = self.search(collection_name, query, top_k=initial_top_k) + if not candidates: + return [] + + if settings.USE_RERANKER and settings.NVIDIA_API_KEY: + try: + from app.services.nvidia_embedder import NvidiaReranker + reranker = NvidiaReranker() + reranked = reranker.rerank(query, candidates, top_k=final_top_k) + return reranked + except Exception as e: + logger.warning("Reranking failed, using embedding results: %s", e) + return candidates[:final_top_k] + return candidates[:final_top_k] + + def delete_passages(self, collection_name: str, passage_ids: List[str]): + try: + collection = self.client.get_collection(collection_name) + collection.delete(ids=passage_ids) + except Exception as e: + logger.warning("Delete failed from %s: %s", collection_name, e) + + def delete_collection(self, collection_name: str): + try: + self.client.delete_collection(collection_name) + except Exception as e: + logger.warning("Delete collection %s failed: %s", collection_name, e) + + def get_collection_stats(self, collection_name: str) -> Dict[str, Any]: + try: + collection = self.client.get_collection(collection_name) + return { + "name": collection_name, + "count": collection.count(), + "provider": self.provider, + "embed_model": settings.NVIDIA_EMBED_MODEL if self.provider and self.provider.startswith("nvidia") else settings.EMBEDDING_MODEL, + "reranker": settings.NVIDIA_RERANK_MODEL if settings.USE_RERANKER else "disabled", + } + except Exception: + return {"name": collection_name, "count": 0} \ No newline at end of file diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/utils/email.py b/app/utils/email.py new file mode 100644 index 0000000000000000000000000000000000000000..973101d273bc308c967a0ca0798d7d67186ee802 --- /dev/null +++ b/app/utils/email.py @@ -0,0 +1,53 @@ +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from app.config import settings + +def send_reply_email(to_email: str, original_subject: str, reply_content: str): + """ + Sends a professional reply via Gmail SMTP. + Requires MAIL_PASSWORD to be an App Password if using Gmail. + """ + if not settings.MAIL_PASSWORD: + print("MAIL_PASSWORD not set. Recording reply in DB only.") + return False + + try: + msg = MIMEMultipart() + msg['From'] = f"Examinal Support <{settings.MAIL_FROM}>" + msg['To'] = to_email + msg['Subject'] = f"Response: {original_subject}" + + # High-Fidelity Professional Template + body = f""" +Hello, + +This is an official response from the Examinal Assessment Portal regarding your inquiry. + +-------------------------------------------------------------------------------- +REPLY CONTENT: +{reply_content} +-------------------------------------------------------------------------------- + +If you have further questions, please maintain this thread. + +Best regards, +Examinal Administration Node +{settings.MAIL_FROM} + """ + msg.attach(MIMEText(body, 'plain')) + + # SMTP Handshake + server = smtplib.SMTP(settings.MAIL_SERVER, settings.MAIL_PORT) + if settings.MAIL_USE_TLS: + server.starttls() + + server.login(settings.MAIL_USERNAME, settings.MAIL_PASSWORD) + text = msg.as_string() + server.sendmail(settings.MAIL_FROM, to_email, text) + server.quit() + print(f"Email successfully transmitted to {to_email}") + return True + except Exception as e: + print(f"Email transmission failure: {e}") + return False diff --git a/app/utils/file_parser.py b/app/utils/file_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..5fd3ea0a5bd3b48bf09557b6ba066ee4ffd8e730 --- /dev/null +++ b/app/utils/file_parser.py @@ -0,0 +1,73 @@ +""" +Parse PDF, DOCX, and PPTX files into page‑level text dicts. +Returns: List[{"text": str, "page": int | None}] +""" + +import logging +from typing import List, Dict, Any + +logger = logging.getLogger(__name__) + + +def parse_pdf(path: str) -> List[Dict[str, Any]]: + import pdfplumber + + pages: List[Dict[str, Any]] = [] + with pdfplumber.open(path) as pdf: + for i, page in enumerate(pdf.pages): + text = page.extract_text() or "" + # Also try extracting tables + tables = page.extract_tables() or [] + for table in tables: + for row in table: + if row: + text += "\n" + " | ".join(str(cell or "") for cell in row) + pages.append({"text": text, "page": i + 1}) + logger.info("Parsed PDF %s: %d pages", path, len(pages)) + return pages + + +def parse_docx(path: str) -> List[Dict[str, Any]]: + from docx import Document + + doc = Document(path) + full_text = [] + for para in doc.paragraphs: + if para.text.strip(): + full_text.append(para.text) + + # Also extract text from tables + for table in doc.tables: + for row in table.rows: + row_text = " | ".join(cell.text.strip() for cell in row.cells if cell.text.strip()) + if row_text: + full_text.append(row_text) + + combined = "\n".join(full_text) + logger.info("Parsed DOCX %s: %d chars", path, len(combined)) + return [{"text": combined, "page": None}] + + +def parse_pptx(path: str) -> List[Dict[str, Any]]: + from pptx import Presentation + + prs = Presentation(path) + pages: List[Dict[str, Any]] = [] + for i, slide in enumerate(prs.slides): + texts = [] + for shape in slide.shapes: + if shape.has_text_frame: + for paragraph in shape.text_frame.paragraphs: + text = paragraph.text.strip() + if text: + texts.append(text) + if shape.has_table: + for row in shape.table.rows: + row_text = " | ".join(cell.text.strip() for cell in row.cells if cell.text.strip()) + if row_text: + texts.append(row_text) + page_text = "\n".join(texts) + if page_text: + pages.append({"text": page_text, "page": i + 1}) + logger.info("Parsed PPTX %s: %d slides", path, len(pages)) + return pages \ No newline at end of file diff --git a/app/utils/security.py b/app/utils/security.py new file mode 100644 index 0000000000000000000000000000000000000000..4d91d035ad11bf5bb5c04b9df94d9828e1a30d00 --- /dev/null +++ b/app/utils/security.py @@ -0,0 +1,18 @@ +import bcrypt + +def hash_password(plain: str) -> str: + # Bcrypt requires bytes + # Limit to 72 bytes as per bcrypt specification + byte_pwd = plain.encode("utf-8")[:72] + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(byte_pwd, salt) + return hashed.decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + try: + byte_pwd = plain.encode("utf-8")[:72] + byte_hashed = hashed.encode("utf-8") + return bcrypt.checkpw(byte_pwd, byte_hashed) + except Exception: + return False \ No newline at end of file diff --git a/create_db.py b/create_db.py new file mode 100644 index 0000000000000000000000000000000000000000..af79c53a913c2c9ece9d54d999c96cbe56192024 --- /dev/null +++ b/create_db.py @@ -0,0 +1,48 @@ +import pymysql +from app.config import settings + +def create_db(): + # Parse connection string + # mysql+pymysql://root:@localhost:3306/examinal + url = settings.DATABASE_URL + if not url.startswith("mysql"): + print("Not a MySQL connection string.") + return + + # Extract info (very simple parsing) + # This assumes mysql+pymysql://user:pass@host:port/db + parts = url.split("://")[1].split("/") + base_url = parts[0] + db_name = parts[1] + + auth_host = base_url.split("@") + if len(auth_host) > 1: + user_pass = auth_host[0].split(":") + user = user_pass[0] + password = user_pass[1] if len(user_pass) > 1 else "" + host_port = auth_host[1].split(":") + host = host_port[0] + port = int(host_port[1]) if len(host_port) > 1 else 3306 + else: + user = "root" + password = "" + host_port = auth_host[0].split(":") + host = host_port[0] + port = int(host_port[1]) if len(host_port) > 1 else 3306 + + try: + conn = pymysql.connect( + host=host, + port=port, + user=user, + password=password + ) + cursor = conn.cursor() + cursor.execute(f"CREATE DATABASE IF NOT EXISTS {db_name}") + print(f"Database '{db_name}' ensured.") + conn.close() + except Exception as e: + print(f"Error creating database: {e}") + +if __name__ == "__main__": + create_db() diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..b2b303f4123927dbf4ff6803d000f756f83dbbba --- /dev/null +++ b/main.py @@ -0,0 +1,73 @@ +""" +Examinal – AI‑powered assessment platform. +Entry‑point: uvicorn main:app --reload +""" + +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.database import engine, Base +from app.middleware.activity_logger import ActivityLoggerMiddleware + +# ── Import every model so Base.metadata knows them ── +from app.models import ( + user, course, content, exam, question, submission, activity_log, +) + +from app.routers import ( + auth, users, courses, content as content_router, + questions, exams, submissions, grading, analytics, admin, +) +from app.routers.contact import router as contact_router + + +@asynccontextmanager +async def lifespan(application: FastAPI): + # ── Startup ── + Path(settings.UPLOAD_DIR).mkdir(parents=True, exist_ok=True) + Path(settings.VECTOR_STORE_DIR).mkdir(parents=True, exist_ok=True) + Base.metadata.create_all(bind=engine) + yield + # ── Shutdown ── + + +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description="AI‑powered end‑to‑end assessment platform", + lifespan=lifespan, +) + +# ── CORS ── +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ── Custom middleware ── +app.add_middleware(ActivityLoggerMiddleware) + +# ── Routers ── +app.include_router(auth.router) +app.include_router(users.router) +app.include_router(courses.router) +app.include_router(content_router.router) +app.include_router(questions.router) +app.include_router(exams.router) +app.include_router(submissions.router) +app.include_router(grading.router) +app.include_router(analytics.router) +app.include_router(admin.router) +app.include_router(contact_router) + + +@app.get("/", tags=["Health"]) +def health_check(): + return {"status": "healthy", "app": settings.APP_NAME, "version": settings.APP_VERSION} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..fc5a85594c43568d601b0f18bdd2ce41db9d45f2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,30 @@ +fastapi==0.135.1 +uvicorn[standard]==0.42.0 +sqlalchemy==2.0.48 +pymysql==1.1.2 +cryptography==46.0.5 +pydantic==2.12.5 +pydantic-settings==2.13.1 +python-jose[cryptography]==3.5.0 +passlib[bcrypt]==1.7.4 +bcrypt==5.0.0 +python-multipart==0.0.22 +python-dotenv==1.2.2 +pdfplumber==0.11.9 +python-docx==1.2.0 +python-pptx==1.0.2 +langchain==1.2.12 +langchain-nvidia-ai-endpoints +langchain-community==0.4.1 +langchain-community==0.4.1 +langchain-text-splitters==1.1.1 +chromadb==1.5.5 +# sentence-transformers removed in favor of NVIDIA Embeddings +# openai removed +# google-generativeai removed +numpy==2.4.3 +aiofiles==25.1.0 +jinja2==3.1.6 +httpx==0.28.1 +alembic==1.18.4 +requests diff --git a/seed_admin.py b/seed_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..4629c4722b529b6be69db21e8ae2ac6076dc5ba9 --- /dev/null +++ b/seed_admin.py @@ -0,0 +1,57 @@ +import sys +import os +sys.path.append(os.getcwd()) + +from app.database import SessionLocal, engine, Base +from app.models.user import User +from app.utils.security import hash_password + +def seed_users(): + # Ensure tables exist + print("Creating tables in MySQL...") + Base.metadata.create_all(bind=engine) + + db = SessionLocal() + try: + users_to_create = [ + { + "username": "admin", + "email": "admin@examinal.com", + "password": "admin123", + "full_name": "System Administrator", + "role": "admin" + }, + { + "username": "instructor1", + "email": "instructor1@examinal.com", + "password": "instructor123", + "full_name": "Exam Instructor", + "role": "instructor" + } + ] + + for u in users_to_create: + existing = db.query(User).filter(User.username == u["username"]).first() + if not existing: + user = User( + username=u["username"], + email=u["email"], + hashed_password=hash_password(u["password"]), + full_name=u["full_name"], + role=u["role"], + is_active=True + ) + db.add(user) + print(f"Created user: {u['username']} (Password: {u['password']})") + else: + print(f"User {u['username']} already exists.") + + db.commit() + except Exception as e: + print(f"Error seeding users: {e}") + db.rollback() + finally: + db.close() + +if __name__ == "__main__": + seed_users()