aryanmirza01 commited on
Commit
419bd6e
·
0 Parent(s):

FYP Clean Final Push

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +9 -0
  2. Dockerfile +19 -0
  3. README.md +70 -0
  4. alembic.ini +36 -0
  5. alembic/env.py +43 -0
  6. app/__init__.py +0 -0
  7. app/config.py +75 -0
  8. app/database.py +33 -0
  9. app/dependencies.py +54 -0
  10. app/middleware/__init__.py +0 -0
  11. app/middleware/activity_logger.py +30 -0
  12. app/models/__init__.py +23 -0
  13. app/models/activity_log.py +22 -0
  14. app/models/contact.py +27 -0
  15. app/models/content.py +37 -0
  16. app/models/course.py +39 -0
  17. app/models/exam.py +47 -0
  18. app/models/question.py +26 -0
  19. app/models/submission.py +44 -0
  20. app/models/user.py +30 -0
  21. app/routers/__init__.py +0 -0
  22. app/routers/admin.py +93 -0
  23. app/routers/analytics.py +50 -0
  24. app/routers/auth.py +46 -0
  25. app/routers/contact.py +48 -0
  26. app/routers/content.py +157 -0
  27. app/routers/courses.py +276 -0
  28. app/routers/exams.py +156 -0
  29. app/routers/grading.py +152 -0
  30. app/routers/questions.py +99 -0
  31. app/routers/submissions.py +241 -0
  32. app/routers/users.py +74 -0
  33. app/schemas/__init__.py +0 -0
  34. app/schemas/analytics.py +46 -0
  35. app/schemas/contact.py +32 -0
  36. app/schemas/content.py +37 -0
  37. app/schemas/course.py +46 -0
  38. app/schemas/exam.py +55 -0
  39. app/schemas/question.py +65 -0
  40. app/schemas/submission.py +59 -0
  41. app/schemas/user.py +47 -0
  42. app/services/__init__.py +0 -0
  43. app/services/analytics_service.py +179 -0
  44. app/services/auth_service.py +84 -0
  45. app/services/contact_service.py +40 -0
  46. app/services/content_ingestion.py +54 -0
  47. app/services/grading_service.py +531 -0
  48. app/services/nvidia_embedder.py +284 -0
  49. app/services/question_generator.py +273 -0
  50. app/services/rag_pipeline.py +157 -0
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ .env
4
+ *.pyc
5
+ *.db
6
+ *.sqlite3
7
+ *.zip
8
+ uploads/
9
+ vector_store_data/
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use official Python image
2
+ FROM python:3.9
3
+
4
+ # Create a non-root user for Hugging Face security
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+ ENV HOME=/home/user \
8
+ PATH=/home/user/.local/bin:$PATH
9
+
10
+ WORKDIR $HOME/app
11
+
12
+ # Copy your backend files into the container
13
+ COPY --chown=user . .
14
+
15
+ # Install the dependencies
16
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
17
+
18
+ # Hugging Face Spaces uses port 7860 by default
19
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 1. Create virtual env
2
+ python -m venv venv
3
+ source venv/bin/activate # Windows: venv\Scripts\activate
4
+
5
+ # 2. Install deps
6
+ pip install -r requirements.txt
7
+
8
+ # 3. Create .env from example and fill in your API keys
9
+ cp .env.example .env
10
+
11
+ # 4. Run the server
12
+ uvicorn main:app --reload --host 0.0.0.0 --port 8000
13
+
14
+ # 5. Open API docs
15
+ # http://localhost:8000/docs
16
+
17
+ # 6. Create admin user
18
+ ```python -c "
19
+ from app.database import SessionLocal, engine, Base
20
+ from app.models.user import User
21
+ from app.utils.security import hash_password
22
+ Base.metadata.create_all(bind=engine)
23
+ db = SessionLocal()
24
+ if not db.query(User).filter(User.role == 'admin').first():
25
+ db.add(User(
26
+ email='admin@examinal.com',
27
+ username='admin',
28
+ hashed_password=hash_password('admin123'),
29
+ full_name='System Admin',
30
+ role='admin',
31
+ ))
32
+ db.commit()
33
+ print('Admin created: admin / admin123')
34
+ else:
35
+ print('Admin already exists')
36
+ db.close()
37
+ ```
38
+
39
+
40
+ ## API Endpoint Summary
41
+
42
+ | Method | Endpoint | Role | Purpose |
43
+ | --- | --- | --- | --- |
44
+ | POST | /api/auth/register | Public | Register |
45
+ | POST | /api/auth/login | Public | Login → JWT |
46
+ | POST | /api/auth/refresh | Any | Refresh token |
47
+ | GET | /api/auth/me | Any | Current user |
48
+ | GET/PATCH/DELETE | /api/users/{id} | Admin/self | User CRUD |
49
+ | POST/GET/PATCH/DELETE | /api/courses/... | Instructor+ | Course CRUD |
50
+ | POST | /api/courses/{id}/enroll | Instructor+ | Enroll student |
51
+ | POST | /api/content/upload/{course_id} | Instructor+ | Upload file |
52
+ | POST | /api/content/ingest/{doc_id} | Instructor+ | Parse & embed |
53
+ | POST | /api/questions/generate | Instructor+ | RAG question gen |
54
+ | CRUD | /api/questions/... | Instructor+ | Manual question CRUD |
55
+ | CRUD | /api/exams/... | Instructor+ | Exam lifecycle |
56
+ | POST | /api/exams/{id}/publish | Instructor+ | Publish exam |
57
+ | POST | /api/exams/{id}/assign | Instructor+ | Assign students |
58
+ | POST | /api/submissions/start | Student | Begin exam |
59
+ | POST | /api/submissions/{id}/autosave | Student | Save progress |
60
+ | POST | /api/submissions/{id}/submit | Student | Final submit |
61
+ | POST | /api/submissions/activity | Student | Log proctoring event |
62
+ | POST | /api/grading/auto/{sub_id} | Instructor+ | Auto‑grade |
63
+ | POST | /api/grading/auto/exam/{id} | Instructor+ | Batch auto‑grade |
64
+ | PATCH | /api/grading/manual/{ans_id} | Instructor+ | Override score |
65
+ | GET | /api/analytics/exam/{id} | Instructor+ | Exam stats |
66
+ | GET | /api/analytics/student/{id} | Instructor+/self | Performance |
67
+ | GET | /api/analytics/course/{id} | Instructor+ | Course overview |
68
+ | GET | /api/admin/stats | Admin | Platform stats |
69
+ | GET | /api/admin/activity-logs | Admin | Audit trail |
70
+ | GET | /api/admin/exam/{id}/integrity | Admin | Cheating flags |
alembic.ini ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [alembic]
2
+ script_location = alembic
3
+ sqlalchemy.url = sqlite:///./examinal.db
4
+
5
+ [loggers]
6
+ keys = root,sqlalchemy,alembic
7
+
8
+ [handlers]
9
+ keys = console
10
+
11
+ [formatters]
12
+ keys = generic
13
+
14
+ [logger_root]
15
+ level = WARN
16
+ handlers = console
17
+
18
+ [logger_sqlalchemy]
19
+ level = WARN
20
+ handlers =
21
+ qualname = sqlalchemy.engine
22
+
23
+ [logger_alembic]
24
+ level = INFO
25
+ handlers =
26
+ qualname = alembic
27
+
28
+ [handler_console]
29
+ class = StreamHandler
30
+ args = (sys.stderr,)
31
+ level = NOTSET
32
+ formatter = generic
33
+
34
+ [formatter_generic]
35
+ format = %(levelname)-5.5s [%(name)s] %(message)s
36
+ datefmt = %H:%M:%S
alembic/env.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from logging.config import fileConfig
2
+
3
+ from sqlalchemy import engine_from_config, pool
4
+ from alembic import context
5
+
6
+ from app.database import Base
7
+ from app.config import settings
8
+
9
+ # ── Import models so metadata is populated ──
10
+ from app.models import user, course, content, exam, question, submission, activity_log # noqa
11
+
12
+ config = context.config
13
+ config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
14
+
15
+ if config.config_file_name is not None:
16
+ fileConfig(config.config_file_name)
17
+
18
+ target_metadata = Base.metadata
19
+
20
+
21
+ def run_migrations_offline() -> None:
22
+ url = config.get_main_option("sqlalchemy.url")
23
+ context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
24
+ with context.begin_transaction():
25
+ context.run_migrations()
26
+
27
+
28
+ def run_migrations_online() -> None:
29
+ connectable = engine_from_config(
30
+ config.get_section(config.config_ini_section, {}),
31
+ prefix="sqlalchemy.",
32
+ poolclass=pool.NullPool,
33
+ )
34
+ with connectable.connect() as connection:
35
+ context.configure(connection=connection, target_metadata=target_metadata)
36
+ with context.begin_transaction():
37
+ context.run_migrations()
38
+
39
+
40
+ if context.is_offline_mode():
41
+ run_migrations_offline()
42
+ else:
43
+ run_migrations_online()
app/__init__.py ADDED
File without changes
app/config.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Central settings — Full NVIDIA stack configuration.
3
+ """
4
+
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ model_config = SettingsConfigDict(
10
+ env_file=".env", env_file_encoding="utf-8", extra="ignore"
11
+ )
12
+
13
+ APP_NAME: str = "Examinal"
14
+ APP_VERSION: str = "2.0.0"
15
+ DEBUG: bool = False
16
+
17
+ SECRET_KEY: str = "CHANGE-ME"
18
+ ALGORITHM: str = "HS256"
19
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
20
+ REFRESH_TOKEN_EXPIRE_DAYS: int = 7
21
+
22
+ DATABASE_URL: str = "sqlite:///./examinal.db"
23
+
24
+ # ── NVIDIA NIM ──
25
+ NVIDIA_API_KEY: str = ""
26
+ NVIDIA_BASE_URL: str = "https://integrate.api.nvidia.com/v1"
27
+
28
+ # ── LLM ──
29
+ LLM_PROVIDER: str = "nvidia"
30
+ NVIDIA_LLM_MODEL: str = "nvidia/nemotron-3-nano-30b-a3b"
31
+ LLM_TEMPERATURE: float = 0.4
32
+ LLM_MAX_TOKENS: int = 8192
33
+
34
+ # ── Fallback LLM ──
35
+ FALLBACK_LLM_PROVIDER: str = "openai"
36
+ OPENAI_API_KEY: str = ""
37
+ OPENAI_MODEL: str = "gpt-4o-mini"
38
+ GOOGLE_API_KEY: str = ""
39
+ GEMINI_MODEL: str = "gemini-1.5-flash"
40
+
41
+ # ── Embedding ──
42
+ EMBEDDING_PROVIDER: str = "nvidia_api"
43
+ NVIDIA_EMBED_MODEL: str = "nvidia/llama-3.2-nv-embedqa-1b-v2"
44
+ EMBEDDING_MODEL: str = "all-MiniLM-L6-v2"
45
+
46
+ # ── Reranking ──
47
+ USE_RERANKER: bool = True
48
+ NVIDIA_RERANK_MODEL: str = "nvidia/llama-nemotron-rerank-1b-v2"
49
+
50
+ # ── Retrieval ──
51
+ RETRIEVAL_TOP_K: int = 25
52
+ RERANK_TOP_K: int = 6
53
+ CHUNK_SIZE: int = 600
54
+ CHUNK_OVERLAP: int = 150
55
+
56
+ # ── Grading ──
57
+ GRADING_MODE: str = "multi_pass"
58
+ GRADING_CONFIDENCE_THRESHOLD: float = 0.7
59
+ ENABLE_RUBRIC_GRADING: bool = True
60
+
61
+ MAIL_USERNAME: str = "aryanmirza112233@gmail.com"
62
+ MAIL_PASSWORD: str = ""
63
+ MAIL_FROM: str = "aryanmirza112233@gmail.com"
64
+ MAIL_PORT: int = 587
65
+ MAIL_SERVER: str = "smtp.gmail.com"
66
+ MAIL_USE_TLS: bool = True
67
+ MAIL_USE_SSL: bool = False
68
+
69
+ # ── Paths ──
70
+ UPLOAD_DIR: str = "uploads"
71
+ VECTOR_STORE_DIR: str = "vector_store_data"
72
+ MAX_UPLOAD_SIZE_MB: int = 50
73
+
74
+
75
+ settings = Settings()
app/database.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLAlchemy engine & session factory.
3
+ """
4
+
5
+ from sqlalchemy import create_engine
6
+ from sqlalchemy.orm import sessionmaker, DeclarativeBase
7
+
8
+ from app.config import settings
9
+
10
+ connect_args = {}
11
+ if settings.DATABASE_URL.startswith("sqlite"):
12
+ connect_args = {"check_same_thread": False}
13
+
14
+ engine = create_engine(
15
+ settings.DATABASE_URL,
16
+ connect_args=connect_args,
17
+ echo=settings.DEBUG,
18
+ pool_pre_ping=True,
19
+ )
20
+
21
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
22
+
23
+
24
+ class Base(DeclarativeBase):
25
+ pass
26
+
27
+
28
+ def get_db():
29
+ db = SessionLocal()
30
+ try:
31
+ yield db
32
+ finally:
33
+ db.close()
app/dependencies.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared FastAPI dependencies: current‑user injection, role checks.
3
+ """
4
+
5
+ from typing import Annotated, List
6
+
7
+ from fastapi import Depends, HTTPException, status
8
+ from fastapi.security import OAuth2PasswordBearer
9
+ from jose import JWTError, jwt
10
+ from sqlalchemy.orm import Session
11
+
12
+ from app.config import settings
13
+ from app.database import get_db
14
+ from app.models.user import User
15
+
16
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
17
+
18
+
19
+ def get_current_user(
20
+ token: Annotated[str, Depends(oauth2_scheme)],
21
+ db: Session = Depends(get_db),
22
+ ) -> User:
23
+ credentials_exc = HTTPException(
24
+ status_code=status.HTTP_401_UNAUTHORIZED,
25
+ detail="Could not validate credentials",
26
+ headers={"WWW-Authenticate": "Bearer"},
27
+ )
28
+ try:
29
+ payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
30
+ user_id: int | None = payload.get("sub")
31
+ if user_id is None:
32
+ raise credentials_exc
33
+ except JWTError:
34
+ raise credentials_exc
35
+
36
+ user = db.query(User).filter(User.id == int(user_id)).first()
37
+ if user is None or not user.is_active:
38
+ raise credentials_exc
39
+ return user
40
+
41
+
42
+ def require_roles(allowed: List[str]):
43
+ """Return a dependency that ensures the user has one of the allowed roles."""
44
+ def _check(current_user: User = Depends(get_current_user)) -> User:
45
+ if current_user.role not in allowed:
46
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
47
+ return current_user
48
+ return _check
49
+
50
+
51
+ CurrentUser = Annotated[User, Depends(get_current_user)]
52
+ AdminUser = Annotated[User, Depends(require_roles(["admin"]))]
53
+ InstructorUser = Annotated[User, Depends(require_roles(["admin", "instructor"]))]
54
+ StudentUser = Annotated[User, Depends(require_roles(["student"]))]
app/middleware/__init__.py ADDED
File without changes
app/middleware/activity_logger.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Middleware that logs every request for audit purposes.
3
+ """
4
+
5
+ import time
6
+ import logging
7
+
8
+ from starlette.middleware.base import BaseHTTPMiddleware
9
+ from starlette.requests import Request
10
+ from starlette.responses import Response
11
+
12
+ logger = logging.getLogger("examinal.access")
13
+
14
+
15
+ class ActivityLoggerMiddleware(BaseHTTPMiddleware):
16
+ async def dispatch(self, request: Request, call_next) -> Response:
17
+ start = time.perf_counter()
18
+ response: Response = await call_next(request)
19
+ elapsed = time.perf_counter() - start
20
+
21
+ logger.info(
22
+ "%s %s %s %.3fs %s",
23
+ request.client.host if request.client else "-",
24
+ request.method,
25
+ request.url.path,
26
+ elapsed,
27
+ response.status_code,
28
+ )
29
+ response.headers["X-Process-Time"] = f"{elapsed:.4f}"
30
+ return response
app/models/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.models.user import User
2
+ from app.models.course import Course, CourseEnrollment
3
+ from app.models.content import ContentDocument, ContentPassage
4
+ from app.models.exam import Exam, ExamAssignment
5
+ from app.models.question import ExamQuestion
6
+ from app.models.submission import ExamSubmission, AnswerResponse
7
+ from app.models.activity_log import ActivityLog
8
+ from app.models.contact import ContactMessage
9
+
10
+ __all__ = [
11
+ "User",
12
+ "Course",
13
+ "CourseEnrollment",
14
+ "ContentDocument",
15
+ "ContentPassage",
16
+ "Exam",
17
+ "ExamAssignment",
18
+ "ExamQuestion",
19
+ "ExamSubmission",
20
+ "AnswerResponse",
21
+ "ActivityLog",
22
+ "ContactMessage",
23
+ ]
app/models/activity_log.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, JSON
4
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
5
+
6
+ from app.database import Base
7
+
8
+
9
+ class ActivityLog(Base):
10
+ __tablename__ = "activity_logs"
11
+
12
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
13
+ user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
14
+ exam_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
15
+ submission_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
16
+ action_type: Mapped[str] = mapped_column(String(100), nullable=False)
17
+ details: Mapped[dict | None] = mapped_column(JSON, nullable=True)
18
+ ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True)
19
+ user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
20
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
21
+
22
+ user = relationship("User", back_populates="activity_logs")
app/models/contact.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.orm import relationship
2
+ from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
3
+ from datetime import datetime
4
+ from app.database import Base
5
+
6
+ class ContactMessage(Base):
7
+ __tablename__ = "contact_messages"
8
+
9
+ id = Column(Integer, primary_key=True, index=True)
10
+ name = Column(String(255), nullable=False)
11
+ email = Column(String(255), nullable=False)
12
+ subject = Column(String(1000), nullable=True)
13
+ message = Column(Text, nullable=False)
14
+ created_at = Column(DateTime, default=datetime.utcnow)
15
+
16
+ # Many replies
17
+ replies = relationship("ContactReply", back_populates="message")
18
+
19
+ class ContactReply(Base):
20
+ __tablename__ = "contact_replies"
21
+
22
+ id = Column(Integer, primary_key=True, index=True)
23
+ message_id = Column(Integer, ForeignKey("contact_messages.id"), nullable=False)
24
+ content = Column(Text, nullable=False)
25
+ created_at = Column(DateTime, default=datetime.utcnow)
26
+
27
+ message = relationship("ContactMessage", back_populates="replies")
app/models/content.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import String, Integer, Text, DateTime, ForeignKey
4
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
5
+
6
+ from app.database import Base
7
+
8
+
9
+ class ContentDocument(Base):
10
+ __tablename__ = "content_documents"
11
+
12
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
13
+ course_id: Mapped[int] = mapped_column(Integer, ForeignKey("courses.id"), nullable=False)
14
+ filename: Mapped[str] = mapped_column(String(500), nullable=False)
15
+ original_filename: Mapped[str] = mapped_column(String(500), nullable=False)
16
+ file_type: Mapped[str] = mapped_column(String(20), nullable=False) # pdf | docx | pptx
17
+ file_size: Mapped[int] = mapped_column(Integer, nullable=False)
18
+ upload_status: Mapped[str] = mapped_column(String(30), default="uploaded") # uploaded | processing | indexed | failed
19
+ uploaded_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
20
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
21
+
22
+ course = relationship("Course", back_populates="documents")
23
+ passages = relationship("ContentPassage", back_populates="document", cascade="all, delete-orphan")
24
+
25
+
26
+ class ContentPassage(Base):
27
+ __tablename__ = "content_passages"
28
+
29
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
30
+ document_id: Mapped[int] = mapped_column(Integer, ForeignKey("content_documents.id"), nullable=False)
31
+ content: Mapped[str] = mapped_column(Text, nullable=False)
32
+ page_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
33
+ chunk_index: Mapped[int] = mapped_column(Integer, nullable=False)
34
+ embedding_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
35
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
36
+
37
+ document = relationship("ContentDocument", back_populates="passages")
app/models/course.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import String, Integer, Text, DateTime, ForeignKey
4
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
5
+
6
+ from app.database import Base
7
+
8
+
9
+ class Course(Base):
10
+ __tablename__ = "courses"
11
+
12
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
13
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
14
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
15
+ code: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
16
+ instructor_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
17
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
18
+ updated_at: Mapped[datetime] = mapped_column(
19
+ DateTime,
20
+ default=lambda: datetime.now(timezone.utc),
21
+ onupdate=lambda: datetime.now(timezone.utc),
22
+ )
23
+
24
+ instructor = relationship("User", back_populates="courses_created")
25
+ enrollments = relationship("CourseEnrollment", back_populates="course", cascade="all, delete-orphan")
26
+ documents = relationship("ContentDocument", back_populates="course", cascade="all, delete-orphan")
27
+ exams = relationship("Exam", back_populates="course", cascade="all, delete-orphan")
28
+
29
+
30
+ class CourseEnrollment(Base):
31
+ __tablename__ = "course_enrollments"
32
+
33
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
34
+ course_id: Mapped[int] = mapped_column(Integer, ForeignKey("courses.id"), nullable=False)
35
+ student_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
36
+ enrolled_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
37
+
38
+ course = relationship("Course", back_populates="enrollments")
39
+ student = relationship("User", back_populates="enrollments")
app/models/exam.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import String, Integer, Float, Boolean, Text, DateTime, ForeignKey
4
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
5
+
6
+ from app.database import Base
7
+
8
+
9
+ class Exam(Base):
10
+ __tablename__ = "exams"
11
+
12
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
13
+ course_id: Mapped[int] = mapped_column(Integer, ForeignKey("courses.id"), nullable=False)
14
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
15
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
16
+ created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
17
+ duration_minutes: Mapped[int] = mapped_column(Integer, default=60)
18
+ total_marks: Mapped[float] = mapped_column(Float, default=100.0)
19
+ passing_marks: Mapped[float] = mapped_column(Float, default=40.0)
20
+ start_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
21
+ end_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
22
+ is_published: Mapped[bool] = mapped_column(Boolean, default=False)
23
+ shuffle_questions: Mapped[bool] = mapped_column(Boolean, default=False)
24
+ show_results: Mapped[bool] = mapped_column(Boolean, default=True)
25
+ max_attempts: Mapped[int] = mapped_column(Integer, default=1)
26
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
27
+ updated_at: Mapped[datetime] = mapped_column(
28
+ DateTime,
29
+ default=lambda: datetime.now(timezone.utc),
30
+ onupdate=lambda: datetime.now(timezone.utc),
31
+ )
32
+
33
+ course = relationship("Course", back_populates="exams")
34
+ questions = relationship("ExamQuestion", back_populates="exam", cascade="all, delete-orphan")
35
+ assignments = relationship("ExamAssignment", back_populates="exam", cascade="all, delete-orphan")
36
+ submissions = relationship("ExamSubmission", back_populates="exam", cascade="all, delete-orphan")
37
+
38
+
39
+ class ExamAssignment(Base):
40
+ __tablename__ = "exam_assignments"
41
+
42
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
43
+ exam_id: Mapped[int] = mapped_column(Integer, ForeignKey("exams.id"), nullable=False)
44
+ student_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
45
+ assigned_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
46
+
47
+ exam = relationship("Exam", back_populates="assignments")
app/models/question.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import String, Integer, Float, Text, DateTime, ForeignKey, JSON
4
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
5
+
6
+ from app.database import Base
7
+
8
+
9
+ class ExamQuestion(Base):
10
+ __tablename__ = "exam_questions"
11
+
12
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
13
+ exam_id: Mapped[int] = mapped_column(Integer, ForeignKey("exams.id"), nullable=False)
14
+ question_text: Mapped[str] = mapped_column(Text, nullable=False)
15
+ question_type: Mapped[str] = mapped_column(String(30), nullable=False) # mcq | short_answer | descriptive
16
+ options: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {"A":"...","B":"...","C":"...","D":"..."}
17
+ correct_answer: Mapped[str] = mapped_column(Text, nullable=False)
18
+ marks: Mapped[float] = mapped_column(Float, default=1.0)
19
+ explanation: Mapped[str | None] = mapped_column(Text, nullable=True)
20
+ source_passage_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("content_passages.id"), nullable=True)
21
+ difficulty: Mapped[str] = mapped_column(String(20), default="medium") # easy | medium | hard
22
+ order_index: Mapped[int] = mapped_column(Integer, default=0)
23
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
24
+
25
+ exam = relationship("Exam", back_populates="questions")
26
+ answers = relationship("AnswerResponse", back_populates="question", cascade="all, delete-orphan")
app/models/submission.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import String, Integer, Float, Text, Boolean, DateTime, ForeignKey
4
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
5
+
6
+ from app.database import Base
7
+
8
+
9
+ class ExamSubmission(Base):
10
+ __tablename__ = "exam_submissions"
11
+
12
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
13
+ exam_id: Mapped[int] = mapped_column(Integer, ForeignKey("exams.id"), nullable=False)
14
+ student_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
15
+ started_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
16
+ submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
17
+ status: Mapped[str] = mapped_column(String(30), default="in_progress") # in_progress | submitted | graded
18
+ total_score: Mapped[float | None] = mapped_column(Float, nullable=True)
19
+ max_score: Mapped[float | None] = mapped_column(Float, nullable=True)
20
+ percentage: Mapped[float | None] = mapped_column(Float, nullable=True)
21
+ is_passed: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
22
+ graded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
23
+
24
+ exam = relationship("Exam", back_populates="submissions")
25
+ student = relationship("User", back_populates="submissions")
26
+ answers = relationship("AnswerResponse", back_populates="submission", cascade="all, delete-orphan")
27
+
28
+
29
+ class AnswerResponse(Base):
30
+ __tablename__ = "answer_responses"
31
+
32
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
33
+ submission_id: Mapped[int] = mapped_column(Integer, ForeignKey("exam_submissions.id"), nullable=False)
34
+ question_id: Mapped[int] = mapped_column(Integer, ForeignKey("exam_questions.id"), nullable=False)
35
+ student_answer: Mapped[str | None] = mapped_column(Text, nullable=True)
36
+ is_correct: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
37
+ score: Mapped[float] = mapped_column(Float, default=0.0)
38
+ max_score: Mapped[float] = mapped_column(Float, default=1.0)
39
+ ai_feedback: Mapped[str | None] = mapped_column(Text, nullable=True)
40
+ confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True)
41
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
42
+
43
+ submission = relationship("ExamSubmission", back_populates="answers")
44
+ question = relationship("ExamQuestion", back_populates="answers")
app/models/user.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import String, Boolean, DateTime, Integer
4
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
5
+
6
+ from app.database import Base
7
+
8
+
9
+ class User(Base):
10
+ __tablename__ = "users"
11
+
12
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
13
+ email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
14
+ username: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False)
15
+ hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
16
+ full_name: Mapped[str] = mapped_column(String(255), nullable=False)
17
+ role: Mapped[str] = mapped_column(String(20), nullable=False, default="student") # admin | instructor | student
18
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
19
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
20
+ updated_at: Mapped[datetime] = mapped_column(
21
+ DateTime,
22
+ default=lambda: datetime.now(timezone.utc),
23
+ onupdate=lambda: datetime.now(timezone.utc),
24
+ )
25
+
26
+ # relationships
27
+ courses_created = relationship("Course", back_populates="instructor", lazy="selectin")
28
+ enrollments = relationship("CourseEnrollment", back_populates="student", lazy="selectin")
29
+ submissions = relationship("ExamSubmission", back_populates="student", lazy="selectin")
30
+ activity_logs = relationship("ActivityLog", back_populates="user", lazy="selectin")
app/routers/__init__.py ADDED
File without changes
app/routers/admin.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin‑only endpoints: audit logs, stats, DB health.
3
+ """
4
+
5
+ from typing import List
6
+
7
+ from fastapi import APIRouter, Depends, Query
8
+ from sqlalchemy.orm import Session
9
+ from sqlalchemy import func
10
+
11
+ from app.database import get_db
12
+ from app.dependencies import AdminUser
13
+ from app.models.user import User
14
+ from app.models.course import Course
15
+ from app.models.exam import Exam
16
+ from app.models.submission import ExamSubmission
17
+ from app.models.activity_log import ActivityLog
18
+
19
+ router = APIRouter(prefix="/api/admin", tags=["Admin"])
20
+
21
+
22
+ @router.get("/stats")
23
+ def platform_stats(_admin: AdminUser, db: Session = Depends(get_db)): # type: ignore
24
+ return {
25
+ "total_users": db.query(func.count(User.id)).scalar(),
26
+ "total_instructors": db.query(func.count(User.id)).filter(User.role == "instructor").scalar(),
27
+ "total_students": db.query(func.count(User.id)).filter(User.role == "student").scalar(),
28
+ "total_courses": db.query(func.count(Course.id)).scalar(),
29
+ "total_exams": db.query(func.count(Exam.id)).scalar(),
30
+ "total_submissions": db.query(func.count(ExamSubmission.id)).scalar(),
31
+ "published_exams": db.query(func.count(Exam.id)).filter(Exam.is_published == True).scalar(), # noqa
32
+ }
33
+
34
+
35
+ @router.get("/activity-logs")
36
+ def get_activity_logs(
37
+ user_id: int | None = None,
38
+ exam_id: int | None = None,
39
+ action_type: str | None = None,
40
+ skip: int = 0,
41
+ limit: int = Query(default=100, le=500),
42
+ _admin: AdminUser = None, # type: ignore
43
+ db: Session = Depends(get_db),
44
+ ):
45
+ q = db.query(ActivityLog).order_by(ActivityLog.created_at.desc())
46
+ if user_id:
47
+ q = q.filter(ActivityLog.user_id == user_id)
48
+ if exam_id:
49
+ q = q.filter(ActivityLog.exam_id == exam_id)
50
+ if action_type:
51
+ q = q.filter(ActivityLog.action_type == action_type)
52
+ logs = q.offset(skip).limit(limit).all()
53
+ return [
54
+ {
55
+ "id": l.id,
56
+ "user_id": l.user_id,
57
+ "exam_id": l.exam_id,
58
+ "submission_id": l.submission_id,
59
+ "action_type": l.action_type,
60
+ "details": l.details,
61
+ "ip_address": l.ip_address,
62
+ "created_at": l.created_at.isoformat() if l.created_at else None,
63
+ }
64
+ for l in logs
65
+ ]
66
+
67
+
68
+ @router.get("/exam/{exam_id}/integrity")
69
+ def exam_integrity_report(exam_id: int, _admin: AdminUser, db: Session = Depends(get_db)): # type: ignore
70
+ """Suspicious activity summary for an exam."""
71
+ suspicious_actions = ["tab_switch", "copy_attempt", "paste_attempt", "focus_lost"]
72
+ logs = (
73
+ db.query(ActivityLog)
74
+ .filter(ActivityLog.exam_id == exam_id, ActivityLog.action_type.in_(suspicious_actions))
75
+ .all()
76
+ )
77
+ # Group by user
78
+ user_flags: dict = {}
79
+ for log in logs:
80
+ uid = log.user_id
81
+ if uid not in user_flags:
82
+ user_flags[uid] = {"user_id": uid, "events": []}
83
+ user_flags[uid]["events"].append({
84
+ "action": log.action_type,
85
+ "time": log.created_at.isoformat() if log.created_at else None,
86
+ "details": log.details,
87
+ })
88
+
89
+ return {
90
+ "exam_id": exam_id,
91
+ "total_flags": len(logs),
92
+ "flagged_students": list(user_flags.values()),
93
+ }
app/routers/analytics.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Analytics and reporting endpoints.
3
+ """
4
+
5
+ from typing import List
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException
8
+ from sqlalchemy.orm import Session
9
+
10
+ from app.database import get_db
11
+ from app.dependencies import InstructorUser, CurrentUser
12
+ from app.schemas.analytics import ExamAnalytics, QuestionAnalytics, StudentPerformance, CourseAnalytics
13
+ from app.services.analytics_service import AnalyticsService
14
+
15
+ router = APIRouter(prefix="/api/analytics", tags=["Analytics"])
16
+
17
+
18
+ @router.get("/exam/{exam_id}", response_model=ExamAnalytics)
19
+ def exam_analytics(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore
20
+ svc = AnalyticsService(db)
21
+ result = svc.get_exam_analytics(exam_id)
22
+ if not result:
23
+ raise HTTPException(status_code=404, detail="Exam not found")
24
+ return result
25
+
26
+
27
+ @router.get("/exam/{exam_id}/questions", response_model=List[QuestionAnalytics])
28
+ def question_analytics(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore
29
+ svc = AnalyticsService(db)
30
+ return svc.get_question_analytics(exam_id)
31
+
32
+
33
+ @router.get("/student/{student_id}", response_model=StudentPerformance)
34
+ def student_performance(student_id: int, current_user: CurrentUser, db: Session = Depends(get_db)):
35
+ if current_user.role == "student" and current_user.id != student_id:
36
+ raise HTTPException(status_code=403, detail="Access denied")
37
+ svc = AnalyticsService(db)
38
+ result = svc.get_student_performance(student_id)
39
+ if not result:
40
+ raise HTTPException(status_code=404, detail="Student not found")
41
+ return result
42
+
43
+
44
+ @router.get("/course/{course_id}", response_model=CourseAnalytics)
45
+ def course_analytics(course_id: int, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore
46
+ svc = AnalyticsService(db)
47
+ result = svc.get_course_analytics(course_id)
48
+ if not result:
49
+ raise HTTPException(status_code=404, detail="Course not found")
50
+ return result
app/routers/auth.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication endpoints: register, login, refresh, me.
3
+ """
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, status
6
+ from fastapi.security import OAuth2PasswordRequestForm
7
+ from sqlalchemy.orm import Session
8
+
9
+ from app.database import get_db
10
+ from app.dependencies import CurrentUser
11
+ from app.schemas.user import UserCreate, UserOut, Token
12
+ from app.services.auth_service import AuthService
13
+
14
+ router = APIRouter(prefix="/api/auth", tags=["Authentication"])
15
+
16
+
17
+ @router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
18
+ def register(payload: UserCreate, db: Session = Depends(get_db)):
19
+ service = AuthService(db)
20
+ user = service.register(payload)
21
+ return user
22
+
23
+
24
+ @router.post("/login", response_model=Token)
25
+ def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
26
+ service = AuthService(db)
27
+ user = service.authenticate(form.username, form.password)
28
+ if not user:
29
+ raise HTTPException(
30
+ status_code=status.HTTP_401_UNAUTHORIZED,
31
+ detail="Incorrect username or password",
32
+ )
33
+ tokens = service.create_tokens(user)
34
+ return tokens
35
+
36
+
37
+ @router.post("/refresh", response_model=Token)
38
+ def refresh_token(refresh_token: str, db: Session = Depends(get_db)):
39
+ service = AuthService(db)
40
+ tokens = service.refresh(refresh_token)
41
+ return tokens
42
+
43
+
44
+ @router.get("/me", response_model=UserOut)
45
+ def get_me(current_user: CurrentUser):
46
+ return current_user
app/routers/contact.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+ from typing import List
4
+ from app.database import get_db
5
+ from app.schemas.contact import ContactCreate, ContactMessage as ContactSchema, ContactReplyCreate
6
+ from app.services.contact_service import create_contact_message, get_all_contact_messages, reply_to_message
7
+ from app.dependencies import get_current_user
8
+ from app.models.user import User
9
+
10
+ router = APIRouter(prefix="/api/contact", tags=["contact"])
11
+
12
+ @router.post("/", response_model=ContactSchema)
13
+ def submit_contact_form(msg: ContactCreate, db: Session = Depends(get_db)):
14
+ return create_contact_message(db, msg)
15
+
16
+ @router.get("/", response_model=List[ContactSchema])
17
+ def get_contact_messages(
18
+ db: Session = Depends(get_db),
19
+ current_user: User = Depends(get_current_user)
20
+ ):
21
+ if current_user.role != "admin":
22
+ raise HTTPException(
23
+ status_code=status.HTTP_403_FORBIDDEN,
24
+ detail="Contact messages accessible by admin only"
25
+ )
26
+ return get_all_contact_messages(db)
27
+
28
+ @router.post("/{message_id}/reply", response_model=ContactSchema)
29
+ def reply_to_contact(
30
+ message_id: int,
31
+ reply_data: ContactReplyCreate,
32
+ db: Session = Depends(get_db),
33
+ current_user: User = Depends(get_current_user)
34
+ ):
35
+ if current_user.role != "admin":
36
+ raise HTTPException(
37
+ status_code=status.HTTP_403_FORBIDDEN,
38
+ detail="Only admins can reply to messages"
39
+ )
40
+
41
+ msg = reply_to_message(db, message_id, reply_data.reply)
42
+ if not msg:
43
+ raise HTTPException(
44
+ status_code=status.HTTP_404_NOT_FOUND,
45
+ detail="Message not found"
46
+ )
47
+
48
+ return msg
app/routers/content.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Content upload, ingestion, and passage retrieval.
3
+ """
4
+
5
+ import uuid
6
+ from typing import List
7
+ from pathlib import Path
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
10
+ from sqlalchemy.orm import Session
11
+
12
+ from app.config import settings
13
+ from app.database import get_db
14
+ from app.dependencies import InstructorUser
15
+ from app.models.content import ContentDocument, ContentPassage
16
+ from app.models.course import Course
17
+ from app.schemas.content import DocumentOut, PassageOut, IngestionStatus
18
+ from app.services.content_ingestion import ContentIngestionService
19
+ from app.services.vector_store import VectorStoreService
20
+
21
+ router = APIRouter(prefix="/api/content", tags=["Content Ingestion"])
22
+
23
+ ALLOWED_TYPES = {"pdf", "docx", "pptx"}
24
+
25
+
26
+ @router.post("/upload/{course_id}", response_model=DocumentOut, status_code=status.HTTP_201_CREATED)
27
+ async def upload_document(
28
+ course_id: int,
29
+ file: UploadFile = File(...),
30
+ user: InstructorUser = None, # type: ignore
31
+ db: Session = Depends(get_db),
32
+ ):
33
+ course = db.query(Course).filter(Course.id == course_id).first()
34
+ if not course:
35
+ raise HTTPException(status_code=404, detail="Course not found")
36
+ if course.instructor_id != user.id and user.role != "admin":
37
+ raise HTTPException(status_code=403, detail="Not your course")
38
+
39
+ ext = file.filename.rsplit(".", 1)[-1].lower() if file.filename else ""
40
+ if ext not in ALLOWED_TYPES:
41
+ raise HTTPException(status_code=400, detail=f"File type .{ext} not allowed. Use: {ALLOWED_TYPES}")
42
+
43
+ # Read and save
44
+ content_bytes = await file.read()
45
+ if len(content_bytes) > settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024:
46
+ raise HTTPException(status_code=400, detail="File too large")
47
+
48
+ filename = f"{uuid.uuid4().hex}.{ext}"
49
+ save_path = Path(settings.UPLOAD_DIR) / filename
50
+ save_path.write_bytes(content_bytes)
51
+
52
+ doc = ContentDocument(
53
+ course_id=course_id,
54
+ filename=filename,
55
+ original_filename=file.filename or "unknown",
56
+ file_type=ext,
57
+ file_size=len(content_bytes),
58
+ uploaded_by=user.id,
59
+ )
60
+ db.add(doc)
61
+ db.commit()
62
+ db.refresh(doc)
63
+ return doc
64
+
65
+
66
+ @router.post("/ingest/{document_id}", response_model=IngestionStatus)
67
+ def ingest_document(
68
+ document_id: int,
69
+ user: InstructorUser = None, # type: ignore
70
+ db: Session = Depends(get_db),
71
+ ):
72
+ doc = db.query(ContentDocument).filter(ContentDocument.id == document_id).first()
73
+ if not doc:
74
+ raise HTTPException(status_code=404, detail="Document not found")
75
+
76
+ doc.upload_status = "processing"
77
+ db.commit()
78
+
79
+ try:
80
+ ingestion_svc = ContentIngestionService()
81
+ file_path = Path(settings.UPLOAD_DIR) / doc.filename
82
+ passages_data = ingestion_svc.parse_and_chunk(str(file_path), doc.file_type)
83
+
84
+ vs_service = VectorStoreService()
85
+ passage_records: list[ContentPassage] = []
86
+
87
+ for idx, pdata in enumerate(passages_data):
88
+ passage = ContentPassage(
89
+ document_id=doc.id,
90
+ content=pdata["text"],
91
+ page_number=pdata.get("page"),
92
+ chunk_index=idx,
93
+ )
94
+ db.add(passage)
95
+ db.flush()
96
+
97
+ emb_id = vs_service.add_passage(
98
+ collection_name=f"course_{doc.course_id}",
99
+ passage_id=str(passage.id),
100
+ text=pdata["text"],
101
+ metadata={
102
+ "document_id": doc.id,
103
+ "course_id": doc.course_id,
104
+ "page": pdata.get("page"),
105
+ "chunk_index": idx,
106
+ },
107
+ )
108
+ passage.embedding_id = emb_id
109
+ passage_records.append(passage)
110
+
111
+ doc.upload_status = "indexed"
112
+ db.commit()
113
+
114
+ return IngestionStatus(
115
+ document_id=doc.id,
116
+ status="indexed",
117
+ passages_created=len(passage_records),
118
+ message="Content ingested and indexed successfully",
119
+ )
120
+ except Exception as e:
121
+ doc.upload_status = "failed"
122
+ db.commit()
123
+ raise HTTPException(status_code=500, detail=f"Ingestion failed: {str(e)}")
124
+
125
+
126
+ @router.get("/documents/{course_id}", response_model=List[DocumentOut])
127
+ def list_documents(course_id: int, user: InstructorUser = None, db: Session = Depends(get_db)): # type: ignore
128
+ return db.query(ContentDocument).filter(ContentDocument.course_id == course_id).all()
129
+
130
+
131
+ @router.get("/passages/{document_id}", response_model=List[PassageOut])
132
+ def list_passages(document_id: int, user: InstructorUser = None, db: Session = Depends(get_db)): # type: ignore
133
+ return db.query(ContentPassage).filter(ContentPassage.document_id == document_id).all()
134
+
135
+
136
+ @router.delete("/documents/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
137
+ def delete_document(document_id: int, user: InstructorUser = None, db: Session = Depends(get_db)): # type: ignore
138
+ doc = db.query(ContentDocument).filter(ContentDocument.id == document_id).first()
139
+ if not doc:
140
+ raise HTTPException(status_code=404, detail="Document not found")
141
+
142
+ # Remove from vector store
143
+ try:
144
+ vs_service = VectorStoreService()
145
+ passage_ids = [str(p.id) for p in doc.passages]
146
+ if passage_ids:
147
+ vs_service.delete_passages(f"course_{doc.course_id}", passage_ids)
148
+ except Exception:
149
+ pass
150
+
151
+ # Remove file
152
+ file_path = Path(settings.UPLOAD_DIR) / doc.filename
153
+ if file_path.exists():
154
+ file_path.unlink()
155
+
156
+ db.delete(doc)
157
+ db.commit()
app/routers/courses.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Course CRUD + enrollment — with student search and flexible enrollment.
3
+ """
4
+
5
+ from typing import List, Optional
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
8
+ from sqlalchemy.orm import Session
9
+ from sqlalchemy import or_
10
+
11
+ from app.database import get_db
12
+ from app.dependencies import CurrentUser, InstructorUser
13
+ from app.models.course import Course, CourseEnrollment
14
+ from app.models.user import User
15
+ from app.schemas.course import (
16
+ CourseCreate, CourseUpdate, CourseOut,
17
+ EnrollmentCreate, EnrollmentOut,
18
+ )
19
+
20
+ router = APIRouter(prefix="/api/courses", tags=["Courses"])
21
+
22
+
23
+ # ═══════════════════════════════════════════
24
+ # COURSE CRUD
25
+ # ═══════════════════════════════════════════
26
+
27
+
28
+ @router.post("/", response_model=CourseOut, status_code=status.HTTP_201_CREATED)
29
+ def create_course(payload: CourseCreate, user: InstructorUser, db: Session = Depends(get_db)):
30
+ existing = db.query(Course).filter(Course.code == payload.code).first()
31
+ if existing:
32
+ raise HTTPException(status_code=400, detail="Course code already exists")
33
+ course = Course(**payload.model_dump(), instructor_id=user.id)
34
+ db.add(course)
35
+ db.commit()
36
+ db.refresh(course)
37
+ return course
38
+
39
+
40
+ @router.get("/", response_model=List[CourseOut])
41
+ def list_courses(current_user: CurrentUser, db: Session = Depends(get_db)):
42
+ if current_user.role in ("admin",):
43
+ return db.query(Course).all()
44
+ if current_user.role == "instructor":
45
+ return db.query(Course).filter(Course.instructor_id == current_user.id).all()
46
+ # student — enrolled courses
47
+ enrollments = db.query(CourseEnrollment).filter(
48
+ CourseEnrollment.student_id == current_user.id
49
+ ).all()
50
+ course_ids = [e.course_id for e in enrollments]
51
+ if not course_ids:
52
+ return []
53
+ return db.query(Course).filter(Course.id.in_(course_ids)).all()
54
+
55
+
56
+ @router.get("/{course_id}", response_model=CourseOut)
57
+ def get_course(course_id: int, current_user: CurrentUser, db: Session = Depends(get_db)):
58
+ course = db.query(Course).filter(Course.id == course_id).first()
59
+ if not course:
60
+ raise HTTPException(status_code=404, detail="Course not found")
61
+ return course
62
+
63
+
64
+ @router.patch("/{course_id}", response_model=CourseOut)
65
+ def update_course(
66
+ course_id: int, payload: CourseUpdate,
67
+ user: InstructorUser, db: Session = Depends(get_db),
68
+ ):
69
+ course = db.query(Course).filter(Course.id == course_id).first()
70
+ if not course:
71
+ raise HTTPException(status_code=404, detail="Course not found")
72
+ if course.instructor_id != user.id and user.role != "admin":
73
+ raise HTTPException(status_code=403, detail="Not your course")
74
+ for k, v in payload.model_dump(exclude_unset=True).items():
75
+ setattr(course, k, v)
76
+ db.commit()
77
+ db.refresh(course)
78
+ return course
79
+
80
+
81
+ @router.delete("/{course_id}", status_code=status.HTTP_204_NO_CONTENT)
82
+ def delete_course(course_id: int, user: InstructorUser, db: Session = Depends(get_db)):
83
+ course = db.query(Course).filter(Course.id == course_id).first()
84
+ if not course:
85
+ raise HTTPException(status_code=404, detail="Course not found")
86
+ if course.instructor_id != user.id and user.role != "admin":
87
+ raise HTTPException(status_code=403, detail="Not your course")
88
+ db.delete(course)
89
+ db.commit()
90
+
91
+
92
+ # ═══════════════════════════════════════════
93
+ # STUDENT SEARCH (for enrollment UI)
94
+ # ═══════════════════════════════════════════
95
+
96
+
97
+ @router.get("/{course_id}/search-students")
98
+ def search_students(
99
+ course_id: int,
100
+ q: str = Query(default="", min_length=0, description="Search by name, email, or username"),
101
+ user: InstructorUser = None,
102
+ db: Session = Depends(get_db),
103
+ ):
104
+ """
105
+ Search for students to enroll.
106
+ Returns students NOT already enrolled in this course.
107
+ """
108
+ # Get already enrolled student IDs
109
+ enrolled_ids = [
110
+ e.student_id for e in
111
+ db.query(CourseEnrollment.student_id)
112
+ .filter(CourseEnrollment.course_id == course_id)
113
+ .all()
114
+ ]
115
+
116
+ query = db.query(User).filter(User.role == "student", User.is_active == True) # noqa: E712
117
+
118
+ # Exclude already enrolled
119
+ if enrolled_ids:
120
+ query = query.filter(User.id.notin_(enrolled_ids))
121
+
122
+ # Apply search filter
123
+ if q and q.strip():
124
+ search = f"%{q.strip()}%"
125
+ query = query.filter(
126
+ or_(
127
+ User.full_name.ilike(search),
128
+ User.email.ilike(search),
129
+ User.username.ilike(search),
130
+ )
131
+ )
132
+
133
+ students = query.limit(20).all()
134
+
135
+ return [
136
+ {
137
+ "id": s.id,
138
+ "full_name": s.full_name,
139
+ "email": s.email,
140
+ "username": s.username,
141
+ }
142
+ for s in students
143
+ ]
144
+
145
+
146
+ # ═══════════════════════════════════════════
147
+ # ENROLLMENT
148
+ # ═══════════════════════════════════════════
149
+
150
+
151
+ def _resolve_student(payload: EnrollmentCreate, db: Session) -> User:
152
+ """Find the student by ID, username, or email."""
153
+ student = None
154
+
155
+ if payload.student_id:
156
+ student = db.query(User).filter(
157
+ User.id == payload.student_id,
158
+ User.role == "student",
159
+ ).first()
160
+
161
+ if not student and payload.username:
162
+ student = db.query(User).filter(
163
+ User.username == payload.username,
164
+ User.role == "student",
165
+ ).first()
166
+
167
+ if not student and payload.email:
168
+ student = db.query(User).filter(
169
+ User.email == payload.email,
170
+ User.role == "student",
171
+ ).first()
172
+
173
+ # Last resort: try the student_id as username search
174
+ if not student and payload.student_id:
175
+ # Maybe they typed a username into the ID field
176
+ student = db.query(User).filter(
177
+ User.username == str(payload.student_id),
178
+ User.role == "student",
179
+ ).first()
180
+
181
+ return student
182
+
183
+
184
+ @router.post("/{course_id}/enroll", status_code=201)
185
+ def enroll_student(
186
+ course_id: int,
187
+ payload: EnrollmentCreate,
188
+ user: InstructorUser,
189
+ db: Session = Depends(get_db),
190
+ ):
191
+ course = db.query(Course).filter(Course.id == course_id).first()
192
+ if not course:
193
+ raise HTTPException(status_code=404, detail="Course not found")
194
+
195
+ # Resolve student from ID, username, or email
196
+ student = _resolve_student(payload, db)
197
+ if not student:
198
+ raise HTTPException(
199
+ status_code=404,
200
+ detail="Student not found. Search by name, email, or username using the search field.",
201
+ )
202
+
203
+ # Check duplicate enrollment
204
+ existing = (
205
+ db.query(CourseEnrollment)
206
+ .filter(
207
+ CourseEnrollment.course_id == course_id,
208
+ CourseEnrollment.student_id == student.id,
209
+ )
210
+ .first()
211
+ )
212
+ if existing:
213
+ raise HTTPException(status_code=400, detail=f"{student.full_name} is already enrolled")
214
+
215
+ enrollment = CourseEnrollment(course_id=course_id, student_id=student.id)
216
+ db.add(enrollment)
217
+ db.commit()
218
+ db.refresh(enrollment)
219
+
220
+ return {
221
+ "id": enrollment.id,
222
+ "course_id": enrollment.course_id,
223
+ "student_id": enrollment.student_id,
224
+ "enrolled_at": enrollment.enrolled_at.isoformat(),
225
+ "student_name": student.full_name,
226
+ "student_email": student.email,
227
+ "student_username": student.username,
228
+ }
229
+
230
+
231
+ @router.get("/{course_id}/students")
232
+ def list_enrolled(
233
+ course_id: int,
234
+ user: InstructorUser = None,
235
+ db: Session = Depends(get_db),
236
+ ):
237
+ """List enrolled students with their names and details."""
238
+ enrollments = (
239
+ db.query(CourseEnrollment)
240
+ .filter(CourseEnrollment.course_id == course_id)
241
+ .all()
242
+ )
243
+
244
+ result = []
245
+ for e in enrollments:
246
+ student = db.query(User).filter(User.id == e.student_id).first()
247
+ result.append({
248
+ "id": e.id,
249
+ "course_id": e.course_id,
250
+ "student_id": e.student_id,
251
+ "enrolled_at": e.enrolled_at.isoformat() if e.enrolled_at else None,
252
+ "student_name": student.full_name if student else f"User #{e.student_id}",
253
+ "student_email": student.email if student else "",
254
+ "student_username": student.username if student else "",
255
+ })
256
+ return result
257
+
258
+
259
+ @router.delete("/{course_id}/enroll/{student_id}", status_code=204)
260
+ def unenroll_student(
261
+ course_id: int, student_id: int,
262
+ user: InstructorUser,
263
+ db: Session = Depends(get_db),
264
+ ):
265
+ enrollment = (
266
+ db.query(CourseEnrollment)
267
+ .filter(
268
+ CourseEnrollment.course_id == course_id,
269
+ CourseEnrollment.student_id == student_id,
270
+ )
271
+ .first()
272
+ )
273
+ if not enrollment:
274
+ raise HTTPException(status_code=404, detail="Enrollment not found")
275
+ db.delete(enrollment)
276
+ db.commit()
app/routers/exams.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Exam CRUD, publish, assign.
3
+ """
4
+
5
+ from typing import List
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, status
8
+ from sqlalchemy.orm import Session
9
+
10
+ from app.database import get_db
11
+ from app.dependencies import CurrentUser, InstructorUser
12
+ from app.models.exam import Exam, ExamAssignment
13
+ from app.models.course import Course, CourseEnrollment
14
+ from app.schemas.exam import ExamCreate, ExamUpdate, ExamOut, ExamAssign
15
+ from app.schemas.question import QuestionStudentView
16
+
17
+ router = APIRouter(prefix="/api/exams", tags=["Exams"])
18
+
19
+
20
+ @router.post("/", response_model=ExamOut, status_code=status.HTTP_201_CREATED)
21
+ def create_exam(payload: ExamCreate, user: InstructorUser, db: Session = Depends(get_db)):
22
+ course = db.query(Course).filter(Course.id == payload.course_id).first()
23
+ if not course:
24
+ raise HTTPException(status_code=404, detail="Course not found")
25
+ exam = Exam(**payload.model_dump(), created_by=user.id)
26
+ db.add(exam)
27
+ db.commit()
28
+ db.refresh(exam)
29
+ return exam
30
+
31
+
32
+ @router.get("/", response_model=List[ExamOut])
33
+ def list_exams(
34
+ course_id: int | None = None,
35
+ current_user: CurrentUser = None, # type: ignore
36
+ db: Session = Depends(get_db),
37
+ ):
38
+ q = db.query(Exam)
39
+ if current_user.role == "instructor":
40
+ q = q.filter(Exam.created_by == current_user.id)
41
+ elif current_user.role == "student":
42
+ assigned = db.query(ExamAssignment.exam_id).filter(
43
+ ExamAssignment.student_id == current_user.id
44
+ ).subquery()
45
+ q = q.filter(Exam.id.in_(assigned), Exam.is_published == True) # noqa: E712
46
+ if course_id:
47
+ q = q.filter(Exam.course_id == course_id)
48
+ return q.all()
49
+
50
+
51
+ @router.get("/{exam_id}", response_model=ExamOut)
52
+ def get_exam(exam_id: int, current_user: CurrentUser, db: Session = Depends(get_db)):
53
+ exam = db.query(Exam).filter(Exam.id == exam_id).first()
54
+ if not exam:
55
+ raise HTTPException(status_code=404, detail="Exam not found")
56
+ return exam
57
+
58
+
59
+ @router.patch("/{exam_id}", response_model=ExamOut)
60
+ def update_exam(exam_id: int, payload: ExamUpdate, user: InstructorUser, db: Session = Depends(get_db)):
61
+ exam = db.query(Exam).filter(Exam.id == exam_id).first()
62
+ if not exam:
63
+ raise HTTPException(status_code=404, detail="Exam not found")
64
+ if exam.created_by != user.id and user.role != "admin":
65
+ raise HTTPException(status_code=403, detail="Not your exam")
66
+ for k, v in payload.model_dump(exclude_unset=True).items():
67
+ setattr(exam, k, v)
68
+ db.commit()
69
+ db.refresh(exam)
70
+ return exam
71
+
72
+
73
+ @router.post("/{exam_id}/publish", response_model=ExamOut)
74
+ def publish_exam(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)):
75
+ exam = db.query(Exam).filter(Exam.id == exam_id).first()
76
+ if not exam:
77
+ raise HTTPException(status_code=404, detail="Exam not found")
78
+ if not exam.questions:
79
+ raise HTTPException(status_code=400, detail="Add questions before publishing")
80
+ exam.is_published = True
81
+ db.commit()
82
+ db.refresh(exam)
83
+ return exam
84
+
85
+
86
+ @router.post("/{exam_id}/unpublish", response_model=ExamOut)
87
+ def unpublish_exam(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)):
88
+ exam = db.query(Exam).filter(Exam.id == exam_id).first()
89
+ if not exam:
90
+ raise HTTPException(status_code=404, detail="Exam not found")
91
+ exam.is_published = False
92
+ db.commit()
93
+ db.refresh(exam)
94
+ return exam
95
+
96
+
97
+ @router.post("/{exam_id}/assign")
98
+ def assign_students(exam_id: int, payload: ExamAssign, user: InstructorUser, db: Session = Depends(get_db)):
99
+ exam = db.query(Exam).filter(Exam.id == exam_id).first()
100
+ if not exam:
101
+ raise HTTPException(status_code=404, detail="Exam not found")
102
+
103
+ created = 0
104
+ for sid in payload.student_ids:
105
+ existing = (
106
+ db.query(ExamAssignment)
107
+ .filter(ExamAssignment.exam_id == exam_id, ExamAssignment.student_id == sid)
108
+ .first()
109
+ )
110
+ if not existing:
111
+ db.add(ExamAssignment(exam_id=exam_id, student_id=sid))
112
+ created += 1
113
+ db.commit()
114
+ return {"assigned": created, "total_requested": len(payload.student_ids)}
115
+
116
+
117
+ @router.post("/{exam_id}/assign-all")
118
+ def assign_all_enrolled(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)):
119
+ """Assign all enrolled students of the exam's course."""
120
+ exam = db.query(Exam).filter(Exam.id == exam_id).first()
121
+ if not exam:
122
+ raise HTTPException(status_code=404, detail="Exam not found")
123
+
124
+ enrollments = db.query(CourseEnrollment).filter(CourseEnrollment.course_id == exam.course_id).all()
125
+ created = 0
126
+ for enrollment in enrollments:
127
+ existing = (
128
+ db.query(ExamAssignment)
129
+ .filter(ExamAssignment.exam_id == exam_id, ExamAssignment.student_id == enrollment.student_id)
130
+ .first()
131
+ )
132
+ if not existing:
133
+ db.add(ExamAssignment(exam_id=exam_id, student_id=enrollment.student_id))
134
+ created += 1
135
+ db.commit()
136
+ return {"assigned": created, "total_enrolled": len(enrollments)}
137
+
138
+
139
+ @router.delete("/{exam_id}", status_code=status.HTTP_204_NO_CONTENT)
140
+ def delete_exam(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)):
141
+ exam = db.query(Exam).filter(Exam.id == exam_id).first()
142
+ if not exam:
143
+ raise HTTPException(status_code=404, detail="Exam not found")
144
+ if exam.created_by != user.id and user.role != "admin":
145
+ raise HTTPException(status_code=403, detail="Not your exam")
146
+ db.delete(exam)
147
+ db.commit()
148
+
149
+
150
+ @router.get("/{exam_id}/questions-student", response_model=List[QuestionStudentView])
151
+ def get_exam_questions_student(exam_id: int, current_user: CurrentUser, db: Session = Depends(get_db)):
152
+ """Return questions without correct answers (student view)."""
153
+ exam = db.query(Exam).filter(Exam.id == exam_id, Exam.is_published == True).first() # noqa: E712
154
+ if not exam:
155
+ raise HTTPException(status_code=404, detail="Exam not found or not published")
156
+ return exam.questions
app/routers/grading.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Grading endpoints — auto-grade, batch grade, manual override, confidence review.
3
+ """
4
+
5
+ from typing import List
6
+ from fastapi import APIRouter, Depends, HTTPException, Query
7
+ from sqlalchemy.orm import Session
8
+
9
+ from app.database import get_db
10
+ from app.dependencies import InstructorUser
11
+ from app.models.submission import ExamSubmission, AnswerResponse
12
+ from app.schemas.submission import SubmissionDetail, SubmissionOut, AnswerResponseOut
13
+ from app.services.grading_service import GradingService
14
+ from app.config import settings
15
+
16
+ router = APIRouter(prefix="/api/grading", tags=["Grading"])
17
+
18
+
19
+ @router.post("/auto/{submission_id}", response_model=SubmissionDetail)
20
+ def auto_grade(submission_id: int, user: InstructorUser, db: Session = Depends(get_db)):
21
+ submission = db.query(ExamSubmission).filter(ExamSubmission.id == submission_id).first()
22
+ if not submission:
23
+ raise HTTPException(status_code=404, detail="Submission not found")
24
+ if submission.status not in ("submitted", "graded"):
25
+ raise HTTPException(status_code=400, detail="Submission not yet submitted")
26
+
27
+ grading_svc = GradingService(db)
28
+ grading_svc.grade_submission(submission)
29
+
30
+ answers = db.query(AnswerResponse).filter(AnswerResponse.submission_id == submission.id).all()
31
+ return SubmissionDetail(
32
+ submission=SubmissionOut.model_validate(submission),
33
+ answers=[AnswerResponseOut.model_validate(a) for a in answers],
34
+ )
35
+
36
+
37
+ @router.post("/auto/exam/{exam_id}")
38
+ def auto_grade_all(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)):
39
+ submissions = (
40
+ db.query(ExamSubmission)
41
+ .filter(ExamSubmission.exam_id == exam_id, ExamSubmission.status == "submitted")
42
+ .all()
43
+ )
44
+ grading_svc = GradingService(db)
45
+ graded = 0
46
+ failed = 0
47
+ for sub in submissions:
48
+ try:
49
+ grading_svc.grade_submission(sub)
50
+ graded += 1
51
+ except Exception as e:
52
+ failed += 1
53
+ return {
54
+ "graded": graded,
55
+ "failed": failed,
56
+ "total_submitted": len(submissions),
57
+ "grading_mode": settings.GRADING_MODE,
58
+ "llm_model": settings.NVIDIA_LLM_MODEL,
59
+ }
60
+
61
+
62
+ @router.get("/low-confidence/{exam_id}")
63
+ def get_low_confidence_answers(
64
+ exam_id: int,
65
+ user: InstructorUser,
66
+ threshold: float = Query(default=0.7, ge=0.0, le=1.0),
67
+ db: Session = Depends(get_db),
68
+ ):
69
+ """Get answers where AI grading confidence is below threshold — need human review."""
70
+ submissions = (
71
+ db.query(ExamSubmission)
72
+ .filter(ExamSubmission.exam_id == exam_id, ExamSubmission.status == "graded")
73
+ .all()
74
+ )
75
+ sub_ids = [s.id for s in submissions]
76
+ if not sub_ids:
77
+ return []
78
+
79
+ low_conf = (
80
+ db.query(AnswerResponse)
81
+ .filter(
82
+ AnswerResponse.submission_id.in_(sub_ids),
83
+ AnswerResponse.confidence_score < threshold,
84
+ AnswerResponse.confidence_score.isnot(None),
85
+ )
86
+ .all()
87
+ )
88
+ return [
89
+ {
90
+ "answer_id": a.id,
91
+ "submission_id": a.submission_id,
92
+ "question_id": a.question_id,
93
+ "student_answer": a.student_answer,
94
+ "current_score": a.score,
95
+ "max_score": a.max_score,
96
+ "confidence": a.confidence_score,
97
+ "ai_feedback": a.ai_feedback,
98
+ }
99
+ for a in low_conf
100
+ ]
101
+
102
+
103
+ @router.patch("/manual/{answer_id}")
104
+ def manual_override(
105
+ answer_id: int,
106
+ score: float,
107
+ user: InstructorUser,
108
+ feedback: str | None = None,
109
+ db: Session = Depends(get_db),
110
+ ):
111
+ answer = db.query(AnswerResponse).filter(AnswerResponse.id == answer_id).first()
112
+ if not answer:
113
+ raise HTTPException(status_code=404, detail="Answer not found")
114
+ answer.score = min(score, answer.max_score)
115
+ answer.is_correct = score >= (answer.max_score * 0.7)
116
+ answer.confidence_score = 1.0 # Manual = full confidence
117
+ if feedback:
118
+ answer.ai_feedback = f"[Instructor override] {feedback}"
119
+ else:
120
+ answer.ai_feedback = (answer.ai_feedback or "") + " [Score overridden by instructor]"
121
+ db.commit()
122
+
123
+ # Recalculate submission totals
124
+ submission = db.query(ExamSubmission).filter(ExamSubmission.id == answer.submission_id).first()
125
+ if submission:
126
+ all_answers = db.query(AnswerResponse).filter(AnswerResponse.submission_id == submission.id).all()
127
+ submission.total_score = round(sum(a.score for a in all_answers), 2)
128
+ submission.max_score = round(sum(a.max_score for a in all_answers), 2)
129
+ submission.percentage = round(
130
+ (submission.total_score / submission.max_score * 100) if submission.max_score else 0, 2
131
+ )
132
+ exam = submission.exam
133
+ passing_pct = (exam.passing_marks / exam.total_marks * 100) if exam and exam.total_marks else 40
134
+ submission.is_passed = submission.percentage >= passing_pct
135
+ db.commit()
136
+
137
+ return {"status": "updated", "new_score": answer.score, "confidence": 1.0}
138
+
139
+
140
+ @router.get("/config")
141
+ def grading_config(user: InstructorUser):
142
+ """Return current grading configuration."""
143
+ return {
144
+ "llm_provider": settings.LLM_PROVIDER,
145
+ "llm_model": settings.NVIDIA_LLM_MODEL,
146
+ "embed_model": settings.NVIDIA_EMBED_MODEL,
147
+ "rerank_model": settings.NVIDIA_RERANK_MODEL,
148
+ "grading_mode": settings.GRADING_MODE,
149
+ "confidence_threshold": settings.GRADING_CONFIDENCE_THRESHOLD,
150
+ "rubric_grading": settings.ENABLE_RUBRIC_GRADING,
151
+ "use_reranker": settings.USE_RERANKER,
152
+ }
app/routers/questions.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Question CRUD + AI generation.
3
+ """
4
+
5
+ from typing import List
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, status
8
+ from sqlalchemy.orm import Session
9
+
10
+ from app.database import get_db
11
+ from app.dependencies import InstructorUser
12
+ from app.models.question import ExamQuestion
13
+ from app.models.exam import Exam
14
+ from app.schemas.question import (
15
+ QuestionCreate,
16
+ QuestionUpdate,
17
+ QuestionOut,
18
+ GenerateQuestionsRequest,
19
+ )
20
+ from app.services.question_generator import QuestionGeneratorService
21
+
22
+ router = APIRouter(prefix="/api/questions", tags=["Questions"])
23
+
24
+
25
+ @router.post("/", response_model=QuestionOut, status_code=status.HTTP_201_CREATED)
26
+ def create_question(payload: QuestionCreate, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore
27
+ exam = db.query(Exam).filter(Exam.id == payload.exam_id).first()
28
+ if not exam:
29
+ raise HTTPException(status_code=404, detail="Exam not found")
30
+ question = ExamQuestion(**payload.model_dump())
31
+ db.add(question)
32
+ db.commit()
33
+ db.refresh(question)
34
+ return question
35
+
36
+
37
+ @router.get("/exam/{exam_id}", response_model=List[QuestionOut])
38
+ def list_questions(exam_id: int, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore
39
+ return (
40
+ db.query(ExamQuestion)
41
+ .filter(ExamQuestion.exam_id == exam_id)
42
+ .order_by(ExamQuestion.order_index)
43
+ .all()
44
+ )
45
+
46
+
47
+ @router.get("/{question_id}", response_model=QuestionOut)
48
+ def get_question(question_id: int, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore
49
+ q = db.query(ExamQuestion).filter(ExamQuestion.id == question_id).first()
50
+ if not q:
51
+ raise HTTPException(status_code=404, detail="Question not found")
52
+ return q
53
+
54
+
55
+ @router.patch("/{question_id}", response_model=QuestionOut)
56
+ def update_question(question_id: int, payload: QuestionUpdate, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore
57
+ q = db.query(ExamQuestion).filter(ExamQuestion.id == question_id).first()
58
+ if not q:
59
+ raise HTTPException(status_code=404, detail="Question not found")
60
+ for k, v in payload.model_dump(exclude_unset=True).items():
61
+ setattr(q, k, v)
62
+ db.commit()
63
+ db.refresh(q)
64
+ return q
65
+
66
+
67
+ @router.delete("/{question_id}", status_code=status.HTTP_204_NO_CONTENT)
68
+ def delete_question(question_id: int, user: InstructorUser, db: Session = Depends(get_db)): # type: ignore
69
+ q = db.query(ExamQuestion).filter(ExamQuestion.id == question_id).first()
70
+ if not q:
71
+ raise HTTPException(status_code=404, detail="Question not found")
72
+ db.delete(q)
73
+ db.commit()
74
+
75
+
76
+ @router.post("/generate", response_model=List[QuestionOut])
77
+ def generate_questions(
78
+ payload: GenerateQuestionsRequest,
79
+ user: InstructorUser, # type: ignore
80
+ db: Session = Depends(get_db),
81
+ ):
82
+ """Use RAG + LLM to generate questions from course content."""
83
+ exam = db.query(Exam).filter(Exam.id == payload.exam_id).first()
84
+ if not exam:
85
+ raise HTTPException(status_code=404, detail="Exam not found")
86
+
87
+ try:
88
+ gen_service = QuestionGeneratorService(db)
89
+ questions = gen_service.generate(
90
+ course_id=payload.course_id,
91
+ exam_id=payload.exam_id,
92
+ num_questions=payload.num_questions,
93
+ question_type=payload.question_type,
94
+ difficulty=payload.difficulty,
95
+ topic=payload.topic,
96
+ )
97
+ return questions
98
+ except Exception as e:
99
+ raise HTTPException(status_code=500, detail=f"Generation failed: {str(e)}")
app/routers/submissions.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Exam‑taking flow: start, autosave, submit, activity events.
3
+ """
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import List
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
9
+ from sqlalchemy.orm import Session
10
+
11
+ from app.database import get_db
12
+ from app.dependencies import CurrentUser
13
+ from app.models.exam import Exam, ExamAssignment
14
+ from app.models.submission import ExamSubmission, AnswerResponse
15
+ from app.models.question import ExamQuestion
16
+ from app.models.activity_log import ActivityLog
17
+ from app.schemas.submission import (
18
+ SubmissionStart,
19
+ SubmissionOut,
20
+ SubmissionDetail,
21
+ AnswerResponseOut,
22
+ AutosaveRequest,
23
+ ActivityEvent,
24
+ )
25
+
26
+ router = APIRouter(prefix="/api/submissions", tags=["Submissions"])
27
+
28
+
29
+ @router.post("/start", response_model=SubmissionOut, status_code=status.HTTP_201_CREATED)
30
+ def start_exam(
31
+ payload: SubmissionStart,
32
+ current_user: CurrentUser,
33
+ request: Request,
34
+ db: Session = Depends(get_db),
35
+ ):
36
+ exam = db.query(Exam).filter(Exam.id == payload.exam_id, Exam.is_published == True).first() # noqa: E712
37
+ if not exam:
38
+ raise HTTPException(status_code=404, detail="Exam not found or not published")
39
+
40
+ # Check assignment
41
+ assigned = (
42
+ db.query(ExamAssignment)
43
+ .filter(ExamAssignment.exam_id == exam.id, ExamAssignment.student_id == current_user.id)
44
+ .first()
45
+ )
46
+ if not assigned and current_user.role == "student":
47
+ raise HTTPException(status_code=403, detail="You are not assigned to this exam")
48
+
49
+ # Check max attempts
50
+ existing_count = (
51
+ db.query(ExamSubmission)
52
+ .filter(
53
+ ExamSubmission.exam_id == exam.id,
54
+ ExamSubmission.student_id == current_user.id,
55
+ ExamSubmission.status.in_(["submitted", "graded"]),
56
+ )
57
+ .count()
58
+ )
59
+ if existing_count >= exam.max_attempts:
60
+ raise HTTPException(status_code=400, detail="Maximum attempts reached")
61
+
62
+ # Check for in-progress submission
63
+ in_progress = (
64
+ db.query(ExamSubmission)
65
+ .filter(
66
+ ExamSubmission.exam_id == exam.id,
67
+ ExamSubmission.student_id == current_user.id,
68
+ ExamSubmission.status == "in_progress",
69
+ )
70
+ .first()
71
+ )
72
+ if in_progress:
73
+ return in_progress
74
+
75
+ submission = ExamSubmission(exam_id=exam.id, student_id=current_user.id)
76
+ db.add(submission)
77
+ db.commit()
78
+ db.refresh(submission)
79
+
80
+ # Log
81
+ db.add(ActivityLog(
82
+ user_id=current_user.id,
83
+ exam_id=exam.id,
84
+ submission_id=submission.id,
85
+ action_type="exam_started",
86
+ ip_address=request.client.host if request.client else None,
87
+ user_agent=request.headers.get("user-agent"),
88
+ ))
89
+ db.commit()
90
+
91
+ return submission
92
+
93
+
94
+ @router.post("/{submission_id}/autosave")
95
+ def autosave_answers(
96
+ submission_id: int,
97
+ payload: AutosaveRequest,
98
+ current_user: CurrentUser,
99
+ db: Session = Depends(get_db),
100
+ ):
101
+ submission = db.query(ExamSubmission).filter(
102
+ ExamSubmission.id == submission_id,
103
+ ExamSubmission.student_id == current_user.id,
104
+ ExamSubmission.status == "in_progress",
105
+ ).first()
106
+ if not submission:
107
+ raise HTTPException(status_code=404, detail="Active submission not found")
108
+
109
+ for ans in payload.answers:
110
+ existing = (
111
+ db.query(AnswerResponse)
112
+ .filter(
113
+ AnswerResponse.submission_id == submission.id,
114
+ AnswerResponse.question_id == ans.question_id,
115
+ )
116
+ .first()
117
+ )
118
+ question = db.query(ExamQuestion).filter(ExamQuestion.id == ans.question_id).first()
119
+ if not question:
120
+ continue
121
+ if existing:
122
+ existing.student_answer = ans.student_answer
123
+ else:
124
+ db.add(AnswerResponse(
125
+ submission_id=submission.id,
126
+ question_id=ans.question_id,
127
+ student_answer=ans.student_answer,
128
+ max_score=question.marks,
129
+ ))
130
+ db.commit()
131
+ return {"status": "saved", "answers_count": len(payload.answers)}
132
+
133
+
134
+ @router.post("/{submission_id}/submit", response_model=SubmissionOut)
135
+ def submit_exam(
136
+ submission_id: int,
137
+ payload: AutosaveRequest,
138
+ current_user: CurrentUser,
139
+ request: Request,
140
+ db: Session = Depends(get_db),
141
+ ):
142
+ submission = db.query(ExamSubmission).filter(
143
+ ExamSubmission.id == submission_id,
144
+ ExamSubmission.student_id == current_user.id,
145
+ ExamSubmission.status == "in_progress",
146
+ ).first()
147
+ if not submission:
148
+ raise HTTPException(status_code=404, detail="Active submission not found")
149
+
150
+ # Save final answers
151
+ for ans in payload.answers:
152
+ existing = (
153
+ db.query(AnswerResponse)
154
+ .filter(
155
+ AnswerResponse.submission_id == submission.id,
156
+ AnswerResponse.question_id == ans.question_id,
157
+ )
158
+ .first()
159
+ )
160
+ question = db.query(ExamQuestion).filter(ExamQuestion.id == ans.question_id).first()
161
+ if not question:
162
+ continue
163
+ if existing:
164
+ existing.student_answer = ans.student_answer
165
+ else:
166
+ db.add(AnswerResponse(
167
+ submission_id=submission.id,
168
+ question_id=ans.question_id,
169
+ student_answer=ans.student_answer,
170
+ max_score=question.marks,
171
+ ))
172
+
173
+ submission.status = "submitted"
174
+ submission.submitted_at = datetime.now(timezone.utc)
175
+ db.commit()
176
+ db.refresh(submission)
177
+
178
+ # Log
179
+ db.add(ActivityLog(
180
+ user_id=current_user.id,
181
+ exam_id=submission.exam_id,
182
+ submission_id=submission.id,
183
+ action_type="exam_submitted",
184
+ ip_address=request.client.host if request.client else None,
185
+ user_agent=request.headers.get("user-agent"),
186
+ ))
187
+ db.commit()
188
+
189
+ return submission
190
+
191
+
192
+ @router.get("/{submission_id}", response_model=SubmissionDetail)
193
+ def get_submission(submission_id: int, current_user: CurrentUser, db: Session = Depends(get_db)):
194
+ submission = db.query(ExamSubmission).filter(ExamSubmission.id == submission_id).first()
195
+ if not submission:
196
+ raise HTTPException(status_code=404, detail="Submission not found")
197
+ if current_user.role == "student" and submission.student_id != current_user.id:
198
+ raise HTTPException(status_code=403, detail="Access denied")
199
+
200
+ answers = db.query(AnswerResponse).filter(AnswerResponse.submission_id == submission.id).all()
201
+ return SubmissionDetail(
202
+ submission=SubmissionOut.model_validate(submission),
203
+ answers=[AnswerResponseOut.model_validate(a) for a in answers],
204
+ )
205
+
206
+
207
+ @router.get("/exam/{exam_id}", response_model=List[SubmissionOut])
208
+ def list_exam_submissions(exam_id: int, current_user: CurrentUser, db: Session = Depends(get_db)):
209
+ q = db.query(ExamSubmission).filter(ExamSubmission.exam_id == exam_id)
210
+ if current_user.role == "student":
211
+ q = q.filter(ExamSubmission.student_id == current_user.id)
212
+ return q.all()
213
+
214
+
215
+ @router.get("/my/all", response_model=List[SubmissionOut])
216
+ def my_submissions(current_user: CurrentUser, db: Session = Depends(get_db)):
217
+ return db.query(ExamSubmission).filter(ExamSubmission.student_id == current_user.id).all()
218
+
219
+
220
+ # ── Activity event logging from secure client ──
221
+
222
+
223
+ @router.post("/activity", status_code=status.HTTP_201_CREATED)
224
+ def log_activity(
225
+ payload: ActivityEvent,
226
+ current_user: CurrentUser,
227
+ request: Request,
228
+ db: Session = Depends(get_db),
229
+ ):
230
+ log = ActivityLog(
231
+ user_id=current_user.id,
232
+ exam_id=payload.exam_id,
233
+ submission_id=payload.submission_id,
234
+ action_type=payload.action_type,
235
+ details=payload.details,
236
+ ip_address=request.client.host if request.client else None,
237
+ user_agent=request.headers.get("user-agent"),
238
+ )
239
+ db.add(log)
240
+ db.commit()
241
+ return {"status": "logged"}
app/routers/users.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ User management endpoints.
3
+ """
4
+
5
+ from typing import List
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, status
8
+ from sqlalchemy.orm import Session
9
+
10
+ from app.database import get_db
11
+ from app.dependencies import AdminUser, CurrentUser
12
+ from app.models.user import User
13
+ from app.schemas.user import UserOut, UserUpdate
14
+
15
+ router = APIRouter(prefix="/api/users", tags=["Users"])
16
+
17
+
18
+ @router.get("/", response_model=List[UserOut])
19
+ def list_users(
20
+ role: str | None = None,
21
+ skip: int = 0,
22
+ limit: int = 50,
23
+ _admin: AdminUser = None, # type: ignore
24
+ db: Session = Depends(get_db),
25
+ ):
26
+ q = db.query(User)
27
+ if role:
28
+ q = q.filter(User.role == role)
29
+ return q.offset(skip).limit(limit).all()
30
+
31
+
32
+ @router.get("/{user_id}", response_model=UserOut)
33
+ def get_user(user_id: int, current_user: CurrentUser, db: Session = Depends(get_db)):
34
+ if current_user.role != "admin" and current_user.id != user_id:
35
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
36
+ user = db.query(User).filter(User.id == user_id).first()
37
+ if not user:
38
+ raise HTTPException(status_code=404, detail="User not found")
39
+ return user
40
+
41
+
42
+ @router.patch("/{user_id}", response_model=UserOut)
43
+ def update_user(
44
+ user_id: int,
45
+ payload: UserUpdate,
46
+ current_user: CurrentUser,
47
+ db: Session = Depends(get_db),
48
+ ):
49
+ if current_user.role != "admin" and current_user.id != user_id:
50
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
51
+ user = db.query(User).filter(User.id == user_id).first()
52
+ if not user:
53
+ raise HTTPException(status_code=404, detail="User not found")
54
+
55
+ update_data = payload.model_dump(exclude_unset=True)
56
+ # Only admins can change role/active status
57
+ if current_user.role != "admin":
58
+ update_data.pop("role", None)
59
+ update_data.pop("is_active", None)
60
+
61
+ for k, v in update_data.items():
62
+ setattr(user, k, v)
63
+ db.commit()
64
+ db.refresh(user)
65
+ return user
66
+
67
+
68
+ @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
69
+ def delete_user(user_id: int, _admin: AdminUser, db: Session = Depends(get_db)): # type: ignore
70
+ user = db.query(User).filter(User.id == user_id).first()
71
+ if not user:
72
+ raise HTTPException(status_code=404, detail="User not found")
73
+ db.delete(user)
74
+ db.commit()
app/schemas/__init__.py ADDED
File without changes
app/schemas/analytics.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional, List, Dict
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class ExamAnalytics(BaseModel):
7
+ exam_id: int
8
+ exam_title: str
9
+ total_students: int
10
+ submitted_count: int
11
+ graded_count: int
12
+ average_score: Optional[float]
13
+ highest_score: Optional[float]
14
+ lowest_score: Optional[float]
15
+ pass_rate: Optional[float]
16
+ score_distribution: Dict[str, int]
17
+
18
+
19
+ class QuestionAnalytics(BaseModel):
20
+ question_id: int
21
+ question_text: str
22
+ question_type: str
23
+ total_attempts: int
24
+ correct_count: int
25
+ accuracy_rate: float
26
+ average_score: float
27
+ difficulty_rating: str
28
+
29
+
30
+ class StudentPerformance(BaseModel):
31
+ student_id: int
32
+ student_name: str
33
+ exams_taken: int
34
+ average_score: float
35
+ highest_score: float
36
+ lowest_score: float
37
+ weak_areas: List[str]
38
+
39
+
40
+ class CourseAnalytics(BaseModel):
41
+ course_id: int
42
+ course_title: str
43
+ total_exams: int
44
+ total_students: int
45
+ overall_average: Optional[float]
46
+ exam_summaries: List[ExamAnalytics]
app/schemas/contact.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import EmailStr, BaseModel
2
+ from datetime import datetime
3
+ from typing import Optional, List
4
+
5
+ class ContactCreate(BaseModel):
6
+ name: str
7
+ email: EmailStr
8
+ subject: str
9
+ message: str
10
+
11
+ class ContactReplyCreate(BaseModel):
12
+ reply: str
13
+
14
+ class ContactReply(BaseModel):
15
+ id: int
16
+ content: str
17
+ created_at: datetime
18
+
19
+ class Config:
20
+ from_attributes = True
21
+
22
+ class ContactMessage(BaseModel):
23
+ id: int
24
+ name: str
25
+ email: str
26
+ subject: str
27
+ message: str
28
+ created_at: datetime
29
+ replies: List[ContactReply] = []
30
+
31
+ class Config:
32
+ from_attributes = True
app/schemas/content.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class DocumentOut(BaseModel):
8
+ id: int
9
+ course_id: int
10
+ filename: str
11
+ original_filename: str
12
+ file_type: str
13
+ file_size: int
14
+ upload_status: str
15
+ uploaded_by: int
16
+ created_at: datetime
17
+
18
+ model_config = {"from_attributes": True}
19
+
20
+
21
+ class PassageOut(BaseModel):
22
+ id: int
23
+ document_id: int
24
+ content: str
25
+ page_number: Optional[int]
26
+ chunk_index: int
27
+ embedding_id: Optional[str]
28
+ created_at: datetime
29
+
30
+ model_config = {"from_attributes": True}
31
+
32
+
33
+ class IngestionStatus(BaseModel):
34
+ document_id: int
35
+ status: str
36
+ passages_created: int
37
+ message: str
app/schemas/course.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class CourseCreate(BaseModel):
8
+ title: str = Field(min_length=1, max_length=255)
9
+ description: Optional[str] = None
10
+ code: str = Field(min_length=2, max_length=30)
11
+
12
+
13
+ class CourseUpdate(BaseModel):
14
+ title: Optional[str] = None
15
+ description: Optional[str] = None
16
+
17
+
18
+ class CourseOut(BaseModel):
19
+ id: int
20
+ title: str
21
+ description: Optional[str]
22
+ code: str
23
+ instructor_id: int
24
+ created_at: datetime
25
+
26
+ model_config = {"from_attributes": True}
27
+
28
+
29
+ class EnrollmentCreate(BaseModel):
30
+ """Accept student_id OR username OR email — backend resolves the student."""
31
+ student_id: Optional[int] = None
32
+ username: Optional[str] = None
33
+ email: Optional[str] = None
34
+
35
+
36
+ class EnrollmentOut(BaseModel):
37
+ id: int
38
+ course_id: int
39
+ student_id: int
40
+ enrolled_at: datetime
41
+ # Include student details so frontend can display names
42
+ student_name: Optional[str] = None
43
+ student_email: Optional[str] = None
44
+ student_username: Optional[str] = None
45
+
46
+ model_config = {"from_attributes": True}
app/schemas/exam.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional, List
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class ExamCreate(BaseModel):
8
+ course_id: int
9
+ title: str = Field(min_length=1, max_length=255)
10
+ description: Optional[str] = None
11
+ duration_minutes: int = Field(default=60, ge=5, le=480)
12
+ total_marks: float = Field(default=100, gt=0)
13
+ passing_marks: float = Field(default=40, ge=0)
14
+ start_time: Optional[datetime] = None
15
+ end_time: Optional[datetime] = None
16
+ shuffle_questions: bool = False
17
+ show_results: bool = True
18
+ max_attempts: int = Field(default=1, ge=1, le=10)
19
+
20
+
21
+ class ExamUpdate(BaseModel):
22
+ title: Optional[str] = None
23
+ description: Optional[str] = None
24
+ duration_minutes: Optional[int] = None
25
+ total_marks: Optional[float] = None
26
+ passing_marks: Optional[float] = None
27
+ start_time: Optional[datetime] = None
28
+ end_time: Optional[datetime] = None
29
+ shuffle_questions: Optional[bool] = None
30
+ show_results: Optional[bool] = None
31
+ max_attempts: Optional[int] = None
32
+
33
+
34
+ class ExamOut(BaseModel):
35
+ id: int
36
+ course_id: int
37
+ title: str
38
+ description: Optional[str]
39
+ created_by: int
40
+ duration_minutes: int
41
+ total_marks: float
42
+ passing_marks: float
43
+ start_time: Optional[datetime]
44
+ end_time: Optional[datetime]
45
+ is_published: bool
46
+ shuffle_questions: bool
47
+ show_results: bool
48
+ max_attempts: int
49
+ created_at: datetime
50
+
51
+ model_config = {"from_attributes": True}
52
+
53
+
54
+ class ExamAssign(BaseModel):
55
+ student_ids: List[int]
app/schemas/question.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional, Dict
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class QuestionCreate(BaseModel):
8
+ exam_id: int
9
+ question_text: str
10
+ question_type: str = Field(pattern="^(mcq|short_answer|descriptive)$")
11
+ options: Optional[Dict[str, str]] = None # {"A":"...","B":"...","C":"...","D":"..."}
12
+ correct_answer: str
13
+ marks: float = Field(default=1.0, gt=0)
14
+ explanation: Optional[str] = None
15
+ difficulty: str = Field(default="medium", pattern="^(easy|medium|hard)$")
16
+ order_index: int = 0
17
+
18
+
19
+ class QuestionUpdate(BaseModel):
20
+ question_text: Optional[str] = None
21
+ options: Optional[Dict[str, str]] = None
22
+ correct_answer: Optional[str] = None
23
+ marks: Optional[float] = None
24
+ explanation: Optional[str] = None
25
+ difficulty: Optional[str] = None
26
+ order_index: Optional[int] = None
27
+
28
+
29
+ class QuestionOut(BaseModel):
30
+ id: int
31
+ exam_id: int
32
+ question_text: str
33
+ question_type: str
34
+ options: Optional[Dict[str, str]]
35
+ correct_answer: str
36
+ marks: float
37
+ explanation: Optional[str]
38
+ difficulty: str
39
+ order_index: int
40
+ created_at: datetime
41
+
42
+ model_config = {"from_attributes": True}
43
+
44
+
45
+ class QuestionStudentView(BaseModel):
46
+ """Same as QuestionOut but hides correct_answer and explanation."""
47
+ id: int
48
+ exam_id: int
49
+ question_text: str
50
+ question_type: str
51
+ options: Optional[Dict[str, str]]
52
+ marks: float
53
+ difficulty: str
54
+ order_index: int
55
+
56
+ model_config = {"from_attributes": True}
57
+
58
+
59
+ class GenerateQuestionsRequest(BaseModel):
60
+ course_id: int
61
+ exam_id: int
62
+ num_questions: int = Field(default=5, ge=1, le=50)
63
+ question_type: str = Field(default="mcq", pattern="^(mcq|short_answer|descriptive|mixed)$")
64
+ difficulty: str = Field(default="medium", pattern="^(easy|medium|hard|mixed)$")
65
+ topic: Optional[str] = None
app/schemas/submission.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional, List, Dict
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class AnswerSubmit(BaseModel):
8
+ question_id: int
9
+ student_answer: str
10
+
11
+
12
+ class AutosaveRequest(BaseModel):
13
+ answers: List[AnswerSubmit]
14
+
15
+
16
+ class SubmissionStart(BaseModel):
17
+ exam_id: int
18
+
19
+
20
+ class SubmissionOut(BaseModel):
21
+ id: int
22
+ exam_id: int
23
+ student_id: int
24
+ started_at: datetime
25
+ submitted_at: Optional[datetime]
26
+ status: str
27
+ total_score: Optional[float]
28
+ max_score: Optional[float]
29
+ percentage: Optional[float]
30
+ is_passed: Optional[bool]
31
+ graded_at: Optional[datetime]
32
+
33
+ model_config = {"from_attributes": True}
34
+
35
+
36
+ class AnswerResponseOut(BaseModel):
37
+ id: int
38
+ submission_id: int
39
+ question_id: int
40
+ student_answer: Optional[str]
41
+ is_correct: Optional[bool]
42
+ score: float
43
+ max_score: float
44
+ ai_feedback: Optional[str]
45
+ confidence_score: Optional[float]
46
+
47
+ model_config = {"from_attributes": True}
48
+
49
+
50
+ class SubmissionDetail(BaseModel):
51
+ submission: SubmissionOut
52
+ answers: List[AnswerResponseOut]
53
+
54
+
55
+ class ActivityEvent(BaseModel):
56
+ exam_id: int
57
+ submission_id: int
58
+ action_type: str # tab_switch | copy_attempt | paste_attempt | right_click | focus_lost | focus_gained
59
+ details: Optional[Dict] = None
app/schemas/user.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel, EmailStr, Field
5
+
6
+
7
+ class UserCreate(BaseModel):
8
+ email: EmailStr
9
+ username: str = Field(min_length=3, max_length=100)
10
+ password: str = Field(min_length=6, max_length=128)
11
+ full_name: str = Field(min_length=1, max_length=255)
12
+ role: str = Field(default="student", pattern="^(admin|instructor|student)$")
13
+
14
+
15
+ class UserLogin(BaseModel):
16
+ username: str
17
+ password: str
18
+
19
+
20
+ class UserUpdate(BaseModel):
21
+ full_name: Optional[str] = None
22
+ email: Optional[EmailStr] = None
23
+ is_active: Optional[bool] = None
24
+ role: Optional[str] = Field(default=None, pattern="^(admin|instructor|student)$")
25
+
26
+
27
+ class UserOut(BaseModel):
28
+ id: int
29
+ email: str
30
+ username: str
31
+ full_name: str
32
+ role: str
33
+ is_active: bool
34
+ created_at: datetime
35
+
36
+ model_config = {"from_attributes": True}
37
+
38
+
39
+ class Token(BaseModel):
40
+ access_token: str
41
+ refresh_token: str
42
+ token_type: str = "bearer"
43
+
44
+
45
+ class TokenData(BaseModel):
46
+ sub: int
47
+ role: str
app/services/__init__.py ADDED
File without changes
app/services/analytics_service.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Analytics computations for exams, questions, students, courses.
3
+ """
4
+
5
+ import logging
6
+ from typing import Optional, List
7
+
8
+ from sqlalchemy import func
9
+ from sqlalchemy.orm import Session
10
+
11
+ from app.models.exam import Exam
12
+ from app.models.question import ExamQuestion
13
+ from app.models.submission import ExamSubmission, AnswerResponse
14
+ from app.models.course import Course
15
+ from app.models.user import User
16
+ from app.schemas.analytics import (
17
+ ExamAnalytics,
18
+ QuestionAnalytics,
19
+ StudentPerformance,
20
+ CourseAnalytics,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class AnalyticsService:
27
+ def __init__(self, db: Session):
28
+ self.db = db
29
+
30
+ def get_exam_analytics(self, exam_id: int) -> Optional[ExamAnalytics]:
31
+ exam = self.db.query(Exam).filter(Exam.id == exam_id).first()
32
+ if not exam:
33
+ return None
34
+
35
+ submissions = (
36
+ self.db.query(ExamSubmission).filter(ExamSubmission.exam_id == exam_id).all()
37
+ )
38
+ graded = [s for s in submissions if s.status == "graded"]
39
+ submitted = [s for s in submissions if s.status in ("submitted", "graded")]
40
+ scores = [s.percentage for s in graded if s.percentage is not None]
41
+
42
+ # Score distribution buckets
43
+ dist = {"0-20": 0, "21-40": 0, "41-60": 0, "61-80": 0, "81-100": 0}
44
+ for s in scores:
45
+ if s <= 20:
46
+ dist["0-20"] += 1
47
+ elif s <= 40:
48
+ dist["21-40"] += 1
49
+ elif s <= 60:
50
+ dist["41-60"] += 1
51
+ elif s <= 80:
52
+ dist["61-80"] += 1
53
+ else:
54
+ dist["81-100"] += 1
55
+
56
+ pass_count = sum(1 for s in graded if s.is_passed)
57
+
58
+ return ExamAnalytics(
59
+ exam_id=exam_id,
60
+ exam_title=exam.title,
61
+ total_students=len(submissions),
62
+ submitted_count=len(submitted),
63
+ graded_count=len(graded),
64
+ average_score=round(sum(scores) / len(scores), 2) if scores else None,
65
+ highest_score=max(scores) if scores else None,
66
+ lowest_score=min(scores) if scores else None,
67
+ pass_rate=round(pass_count / len(graded) * 100, 2) if graded else None,
68
+ score_distribution=dist,
69
+ )
70
+
71
+ def get_question_analytics(self, exam_id: int) -> List[QuestionAnalytics]:
72
+ questions = (
73
+ self.db.query(ExamQuestion)
74
+ .filter(ExamQuestion.exam_id == exam_id)
75
+ .order_by(ExamQuestion.order_index)
76
+ .all()
77
+ )
78
+
79
+ analytics = []
80
+ for q in questions:
81
+ answers = self.db.query(AnswerResponse).filter(AnswerResponse.question_id == q.id).all()
82
+ total = len(answers)
83
+ correct = sum(1 for a in answers if a.is_correct)
84
+ avg_score = sum(a.score for a in answers) / total if total else 0
85
+
86
+ accuracy = correct / total if total else 0
87
+ if accuracy >= 0.8:
88
+ rating = "easy"
89
+ elif accuracy >= 0.5:
90
+ rating = "medium"
91
+ else:
92
+ rating = "hard"
93
+
94
+ analytics.append(QuestionAnalytics(
95
+ question_id=q.id,
96
+ question_text=q.question_text[:200],
97
+ question_type=q.question_type,
98
+ total_attempts=total,
99
+ correct_count=correct,
100
+ accuracy_rate=round(accuracy, 3),
101
+ average_score=round(avg_score, 2),
102
+ difficulty_rating=rating,
103
+ ))
104
+
105
+ return analytics
106
+
107
+ def get_student_performance(self, student_id: int) -> Optional[StudentPerformance]:
108
+ student = self.db.query(User).filter(User.id == student_id).first()
109
+ if not student:
110
+ return None
111
+
112
+ submissions = (
113
+ self.db.query(ExamSubmission)
114
+ .filter(ExamSubmission.student_id == student_id, ExamSubmission.status == "graded")
115
+ .all()
116
+ )
117
+ scores = [s.percentage for s in submissions if s.percentage is not None]
118
+
119
+ # Weak areas: questions with low scores
120
+ weak_areas: List[str] = []
121
+ if submissions:
122
+ low_answers = (
123
+ self.db.query(AnswerResponse, ExamQuestion)
124
+ .join(ExamQuestion, AnswerResponse.question_id == ExamQuestion.id)
125
+ .filter(
126
+ AnswerResponse.submission_id.in_([s.id for s in submissions]),
127
+ AnswerResponse.is_correct == False, # noqa: E712
128
+ )
129
+ .limit(10)
130
+ .all()
131
+ )
132
+ seen = set()
133
+ for answer, question in low_answers:
134
+ topic = question.question_text[:80]
135
+ if topic not in seen:
136
+ weak_areas.append(topic)
137
+ seen.add(topic)
138
+
139
+ return StudentPerformance(
140
+ student_id=student_id,
141
+ student_name=student.full_name,
142
+ exams_taken=len(submissions),
143
+ average_score=round(sum(scores) / len(scores), 2) if scores else 0.0,
144
+ highest_score=max(scores) if scores else 0.0,
145
+ lowest_score=min(scores) if scores else 0.0,
146
+ weak_areas=weak_areas[:5],
147
+ )
148
+
149
+ def get_course_analytics(self, course_id: int) -> Optional[CourseAnalytics]:
150
+ course = self.db.query(Course).filter(Course.id == course_id).first()
151
+ if not course:
152
+ return None
153
+
154
+ exams = self.db.query(Exam).filter(Exam.course_id == course_id).all()
155
+ exam_summaries = []
156
+ all_scores = []
157
+
158
+ for exam in exams:
159
+ ea = self.get_exam_analytics(exam.id)
160
+ if ea:
161
+ exam_summaries.append(ea)
162
+ if ea.average_score is not None:
163
+ all_scores.append(ea.average_score)
164
+
165
+ from app.models.course import CourseEnrollment
166
+ total_students = (
167
+ self.db.query(func.count(CourseEnrollment.id))
168
+ .filter(CourseEnrollment.course_id == course_id)
169
+ .scalar()
170
+ ) or 0
171
+
172
+ return CourseAnalytics(
173
+ course_id=course_id,
174
+ course_title=course.title,
175
+ total_exams=len(exams),
176
+ total_students=total_students,
177
+ overall_average=round(sum(all_scores) / len(all_scores), 2) if all_scores else None,
178
+ exam_summaries=exam_summaries,
179
+ )
app/services/auth_service.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication service: registration, login, JWT minting.
3
+ """
4
+
5
+ from datetime import datetime, timedelta, timezone
6
+
7
+ from jose import JWTError, jwt
8
+ from sqlalchemy.orm import Session
9
+ from fastapi import HTTPException, status
10
+
11
+ from app.config import settings
12
+ from app.models.user import User
13
+ from app.schemas.user import UserCreate, Token
14
+ from app.utils.security import hash_password, verify_password
15
+
16
+
17
+ class AuthService:
18
+ def __init__(self, db: Session):
19
+ self.db = db
20
+
21
+ # ── Register ──
22
+ def register(self, payload: UserCreate) -> User:
23
+ email = payload.email.strip()
24
+ username = payload.username.strip()
25
+ if self.db.query(User).filter(User.email == email).first():
26
+ raise HTTPException(status_code=400, detail="Email already registered")
27
+ if self.db.query(User).filter(User.username == username).first():
28
+ raise HTTPException(status_code=400, detail="Username already taken")
29
+
30
+ user = User(
31
+ email=email,
32
+ username=username,
33
+ hashed_password=hash_password(payload.password),
34
+ full_name=payload.full_name,
35
+ role=payload.role,
36
+ )
37
+ self.db.add(user)
38
+ self.db.commit()
39
+ self.db.refresh(user)
40
+ return user
41
+
42
+ # ── Authenticate ──
43
+ def authenticate(self, username: str, password: str) -> User | None:
44
+ username = username.strip()
45
+ user = self.db.query(User).filter(
46
+ (User.username == username) | (User.email == username)
47
+ ).first()
48
+ if not user or not verify_password(password, user.hashed_password):
49
+ return None
50
+ if not user.is_active:
51
+ raise HTTPException(status_code=403, detail="Account deactivated")
52
+ return user
53
+
54
+ # ── Tokens ──
55
+ def _create_token(self, data: dict, expires_delta: timedelta) -> str:
56
+ to_encode = data.copy()
57
+ to_encode["exp"] = datetime.now(timezone.utc) + expires_delta
58
+ return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
59
+
60
+ def create_tokens(self, user: User) -> Token:
61
+ access = self._create_token(
62
+ {"sub": str(user.id), "role": user.role},
63
+ timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
64
+ )
65
+ refresh = self._create_token(
66
+ {"sub": str(user.id), "type": "refresh"},
67
+ timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
68
+ )
69
+ return Token(access_token=access, refresh_token=refresh)
70
+
71
+ # ── Refresh ──
72
+ def refresh(self, refresh_token: str) -> Token:
73
+ try:
74
+ payload = jwt.decode(refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
75
+ if payload.get("type") != "refresh":
76
+ raise HTTPException(status_code=401, detail="Invalid token type")
77
+ user_id = int(payload["sub"])
78
+ except JWTError:
79
+ raise HTTPException(status_code=401, detail="Invalid refresh token")
80
+
81
+ user = self.db.query(User).filter(User.id == user_id).first()
82
+ if not user or not user.is_active:
83
+ raise HTTPException(status_code=401, detail="User not found")
84
+ return self.create_tokens(user)
app/services/contact_service.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.orm import Session, joinedload
2
+ from app.models.contact import ContactMessage, ContactReply
3
+ from app.schemas.contact import ContactCreate
4
+ from app.utils.email import send_reply_email
5
+
6
+ def create_contact_message(db: Session, msg: ContactCreate):
7
+ new_msg = ContactMessage(
8
+ name=msg.name,
9
+ email=msg.email,
10
+ subject=msg.subject,
11
+ message=msg.message
12
+ )
13
+ db.add(new_msg)
14
+ db.commit()
15
+ db.refresh(new_msg)
16
+ return new_msg
17
+
18
+ def get_all_contact_messages(db: Session):
19
+ return db.query(ContactMessage).options(joinedload(ContactMessage.replies)).order_by(ContactMessage.created_at.desc()).all()
20
+
21
+ def reply_to_message(db: Session, message_id: int, reply_text: str):
22
+ msg = db.query(ContactMessage).filter(ContactMessage.id == message_id).first()
23
+ if not msg:
24
+ return None
25
+
26
+ # Create DB record for the reply
27
+ new_reply = ContactReply(
28
+ message_id = message_id,
29
+ content = reply_text
30
+ )
31
+ db.add(new_reply)
32
+ db.commit()
33
+ db.refresh(new_reply)
34
+
35
+ # Attempt to send real email
36
+ send_reply_email(msg.email, msg.subject, reply_text)
37
+
38
+ # Refresh msg to get the new replies list
39
+ db.refresh(msg)
40
+ return msg
app/services/content_ingestion.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Parse uploaded files (PDF, DOCX, PPTX) and chunk them into passages.
3
+ """
4
+
5
+ import logging
6
+ from typing import List, Dict, Any
7
+
8
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
9
+
10
+ from app.config import settings
11
+ from app.utils.file_parser import parse_pdf, parse_docx, parse_pptx
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class ContentIngestionService:
17
+ def __init__(self):
18
+ self.splitter = RecursiveCharacterTextSplitter(
19
+ chunk_size=settings.CHUNK_SIZE,
20
+ chunk_overlap=settings.CHUNK_OVERLAP,
21
+ separators=["\n\n", "\n", ". ", " ", ""],
22
+ )
23
+
24
+ def parse_and_chunk(self, file_path: str, file_type: str) -> List[Dict[str, Any]]:
25
+ """
26
+ Return list of {"text": str, "page": int | None}.
27
+ """
28
+ logger.info("Parsing %s (%s)", file_path, file_type)
29
+
30
+ if file_type == "pdf":
31
+ pages = parse_pdf(file_path)
32
+ elif file_type == "docx":
33
+ pages = parse_docx(file_path)
34
+ elif file_type == "pptx":
35
+ pages = parse_pptx(file_path)
36
+ else:
37
+ raise ValueError(f"Unsupported file type: {file_type}")
38
+
39
+ passages: List[Dict[str, Any]] = []
40
+ for page_data in pages:
41
+ text = page_data["text"].strip()
42
+ if not text:
43
+ continue
44
+ chunks = self.splitter.split_text(text)
45
+ for chunk in chunks:
46
+ if len(chunk.strip()) < 20:
47
+ continue
48
+ passages.append({
49
+ "text": chunk.strip(),
50
+ "page": page_data.get("page"),
51
+ })
52
+
53
+ logger.info("Created %d passages from %s", len(passages), file_path)
54
+ return passages
app/services/grading_service.py ADDED
@@ -0,0 +1,531 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Grading Engine — Multi-pass evaluation with NVIDIA Nemotron.
3
+
4
+ Grading Pipeline:
5
+ 1. MCQ: Exact match (instant, 100% accurate)
6
+ 2. Short Answer: Keyword extraction + semantic LLM scoring
7
+ 3. Descriptive: Multi-pass rubric-based evaluation
8
+ Pass 1: Initial scoring with rubric criteria
9
+ Pass 2: Verification pass — checks for over/under scoring
10
+ Final: Averaged score with confidence calibration
11
+
12
+ This achieves significantly higher grading accuracy than single-pass.
13
+ """
14
+
15
+ import json
16
+ import logging
17
+ from datetime import datetime, timezone
18
+ from typing import Dict, List, Optional
19
+
20
+ from sqlalchemy.orm import Session
21
+
22
+ from app.config import settings
23
+ from app.models.submission import ExamSubmission, AnswerResponse
24
+ from app.models.question import ExamQuestion
25
+ from app.services.rag_pipeline import call_llm
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # ═══════════════════════════════════════════════════════════════
30
+ # GRADING PROMPTS — Optimized for Nemotron accuracy
31
+ # ═══════════════════════════════════════════════════════════════
32
+
33
+ GRADING_SYSTEM = """You are an expert academic exam grader with years of experience.
34
+
35
+ GRADING PRINCIPLES:
36
+ 1. Be FAIR — grade on substance, not phrasing
37
+ 2. Be RIGOROUS — partial credit only for partially correct answers
38
+ 3. Be CONSISTENT — same quality answer always gets same score
39
+ 4. KEY TERMS matter — correct terminology demonstrates understanding
40
+ 5. WRONG facts get ZERO credit for that portion
41
+ 6. You MUST return valid JSON only — no explanation outside JSON"""
42
+
43
+ RUBRIC_GRADING_PROMPT = """Grade this student answer using the rubric below.
44
+
45
+ ═══ QUESTION ═══
46
+ {question}
47
+
48
+ ═══ MODEL ANSWER ═══
49
+ {correct_answer}
50
+
51
+ ═══ EVALUATION CRITERIA ═══
52
+ {rubric}
53
+
54
+ ═══ STUDENT ANSWER ═══
55
+ {student_answer}
56
+
57
+ ═══ SCORING RULES ═══
58
+ Maximum score: {max_score}
59
+ - Full marks: All key concepts present with correct terminology
60
+ - 75% marks: Most concepts correct, minor gaps
61
+ - 50% marks: Core idea understood, significant gaps
62
+ - 25% marks: Some relevant content, major errors
63
+ - 0 marks: Completely wrong, irrelevant, or blank
64
+
65
+ Evaluate step by step:
66
+ 1. List which rubric criteria the student met
67
+ 2. List which criteria are missing or wrong
68
+ 3. Identify any factual errors
69
+ 4. Calculate the score
70
+
71
+ Return ONLY this JSON:
72
+ {{
73
+ "criteria_met": ["criterion 1", "criterion 2"],
74
+ "criteria_missed": ["criterion 3"],
75
+ "factual_errors": ["error description or empty list"],
76
+ "score": <float 0 to {max_score}>,
77
+ "is_correct": <true if score >= 70% of max>,
78
+ "feedback": "<2-3 sentences: what was good, what was wrong, how to improve>",
79
+ "confidence": <float 0.0 to 1.0>
80
+ }}"""
81
+
82
+ SIMPLE_GRADING_PROMPT = """Grade this student answer by comparing to the model answer.
83
+
84
+ ═══ QUESTION ═══
85
+ {question}
86
+
87
+ ═══ MODEL ANSWER ═══
88
+ {correct_answer}
89
+
90
+ ═══ STUDENT ANSWER ═══
91
+ {student_answer}
92
+
93
+ ═══ SCORING ═══
94
+ Maximum score: {max_score}
95
+ Grade based on factual correctness, completeness, and understanding.
96
+
97
+ Return ONLY this JSON:
98
+ {{
99
+ "score": <float 0 to {max_score}>,
100
+ "is_correct": <true if score >= 70% of max>,
101
+ "feedback": "<specific feedback: what's correct, what's wrong>",
102
+ "confidence": <float 0.0 to 1.0>
103
+ }}"""
104
+
105
+ VERIFICATION_PROMPT = """You are a grading auditor. Review this grading result for accuracy.
106
+
107
+ ═══ QUESTION ═══
108
+ {question}
109
+
110
+ ═══ MODEL ANSWER ═══
111
+ {correct_answer}
112
+
113
+ ═══ STUDENT ANSWER ═══
114
+ {student_answer}
115
+
116
+ ═══ INITIAL GRADE ═══
117
+ Score: {initial_score}/{max_score}
118
+ Feedback: {initial_feedback}
119
+
120
+ ═══ YOUR TASK ═══
121
+ Check if the initial grade is fair and accurate:
122
+ 1. Is the score too high? (student got credit for wrong things)
123
+ 2. Is the score too low? (student had correct content that was missed)
124
+ 3. Is the feedback accurate?
125
+
126
+ Return ONLY this JSON:
127
+ {{
128
+ "adjusted_score": <float 0 to {max_score}>,
129
+ "adjustment_reason": "<why you changed or kept the score>",
130
+ "confidence": <float 0.0 to 1.0>
131
+ }}"""
132
+
133
+ SHORT_ANSWER_PROMPT = """Grade this short answer question.
134
+
135
+ ═══ QUESTION ═══
136
+ {question}
137
+
138
+ ═══ MODEL ANSWER ═══
139
+ {correct_answer}
140
+
141
+ ═══ KEY TERMS (must appear for full credit) ═══
142
+ {key_terms}
143
+
144
+ ═══ STUDENT ANSWER ═══
145
+ {student_answer}
146
+
147
+ ═══ SCORING (max {max_score}) ═══
148
+ - All key terms present + correct explanation = full marks
149
+ - Most key terms + partial explanation = 70-90%
150
+ - Some key terms + vague explanation = 40-60%
151
+ - Wrong or irrelevant = 0-20%
152
+
153
+ Return ONLY this JSON:
154
+ {{
155
+ "key_terms_found": ["term1", "term2"],
156
+ "key_terms_missing": ["term3"],
157
+ "score": <float 0 to {max_score}>,
158
+ "is_correct": <true if score >= 70% of max>,
159
+ "feedback": "<specific feedback>",
160
+ "confidence": <float 0.0 to 1.0>
161
+ }}"""
162
+
163
+
164
+ class GradingService:
165
+ def __init__(self, db: Session):
166
+ self.db = db
167
+ self.mode = settings.GRADING_MODE
168
+ self.confidence_threshold = settings.GRADING_CONFIDENCE_THRESHOLD
169
+
170
+ def grade_submission(self, submission: ExamSubmission):
171
+ """Grade all answers in a submission."""
172
+ answers = (
173
+ self.db.query(AnswerResponse)
174
+ .filter(AnswerResponse.submission_id == submission.id)
175
+ .all()
176
+ )
177
+
178
+ total_score = 0.0
179
+ total_max = 0.0
180
+ low_confidence_count = 0
181
+
182
+ for answer in answers:
183
+ question = (
184
+ self.db.query(ExamQuestion)
185
+ .filter(ExamQuestion.id == answer.question_id)
186
+ .first()
187
+ )
188
+ if not question:
189
+ continue
190
+
191
+ answer.max_score = question.marks
192
+ total_max += question.marks
193
+
194
+ if not answer.student_answer or not answer.student_answer.strip():
195
+ answer.score = 0.0
196
+ answer.is_correct = False
197
+ answer.ai_feedback = "No answer provided."
198
+ answer.confidence_score = 1.0
199
+ continue
200
+
201
+ # ── Grade by type ──
202
+ if question.question_type == "mcq":
203
+ self._grade_mcq(answer, question)
204
+ elif question.question_type == "short_answer":
205
+ self._grade_short_answer(answer, question)
206
+ else:
207
+ self._grade_descriptive(answer, question)
208
+
209
+ total_score += answer.score
210
+
211
+ if answer.confidence_score is not None and answer.confidence_score < self.confidence_threshold:
212
+ low_confidence_count += 1
213
+
214
+ # ── Update submission ──
215
+ submission.total_score = round(total_score, 2)
216
+ submission.max_score = round(total_max, 2)
217
+ submission.percentage = round((total_score / total_max * 100), 2) if total_max > 0 else 0
218
+ exam = submission.exam
219
+ passing_pct = (exam.passing_marks / exam.total_marks * 100) if exam and exam.total_marks else 40
220
+ submission.is_passed = submission.percentage >= passing_pct
221
+ submission.status = "graded"
222
+ submission.graded_at = datetime.now(timezone.utc)
223
+
224
+ self.db.commit()
225
+
226
+ logger.info(
227
+ "Graded submission %d: %.1f/%.1f (%.1f%%) — %d low-confidence answers",
228
+ submission.id, total_score, total_max,
229
+ submission.percentage or 0, low_confidence_count,
230
+ )
231
+
232
+ # ═══════════════════════════════════════════════════════════
233
+ # MCQ — Exact match (100% accurate, no LLM needed)
234
+ # ═══════════════════════════════════════════════════════════
235
+
236
+ def _grade_mcq(self, answer: AnswerResponse, question: ExamQuestion):
237
+ student = answer.student_answer.strip().upper()
238
+ correct = question.correct_answer.strip().upper()
239
+
240
+ is_correct = student == correct
241
+ answer.is_correct = is_correct
242
+ answer.score = question.marks if is_correct else 0.0
243
+ answer.confidence_score = 1.0
244
+
245
+ if is_correct:
246
+ answer.ai_feedback = "Correct!"
247
+ else:
248
+ answer.ai_feedback = f"Incorrect. The correct answer is {correct}."
249
+
250
+ if question.explanation:
251
+ answer.ai_feedback += f" {question.explanation}"
252
+
253
+ # ═══════════════════════════════════════════════════════════
254
+ # SHORT ANSWER — Key term matching + LLM evaluation
255
+ # ═══════════════════════════════════════════════════════════
256
+
257
+ def _grade_short_answer(self, answer: AnswerResponse, question: ExamQuestion):
258
+ # Extract key terms from explanation if available
259
+ key_terms = self._extract_key_terms(question)
260
+
261
+ try:
262
+ prompt = SHORT_ANSWER_PROMPT.format(
263
+ question=question.question_text,
264
+ correct_answer=question.correct_answer,
265
+ key_terms=", ".join(key_terms) if key_terms else "Not specified — compare to model answer",
266
+ student_answer=answer.student_answer,
267
+ max_score=question.marks,
268
+ )
269
+ raw = call_llm(prompt, GRADING_SYSTEM, temperature=0.1)
270
+ result = self._parse_grade(raw, question.marks)
271
+
272
+ answer.score = result["score"]
273
+ answer.is_correct = result["is_correct"]
274
+ answer.confidence_score = result["confidence"]
275
+
276
+ # Build detailed feedback
277
+ feedback_parts = [result["feedback"]]
278
+ if result.get("key_terms_found"):
279
+ feedback_parts.append(f"Key terms found: {', '.join(result['key_terms_found'])}")
280
+ if result.get("key_terms_missing"):
281
+ feedback_parts.append(f"Missing: {', '.join(result['key_terms_missing'])}")
282
+ answer.ai_feedback = " | ".join(feedback_parts)
283
+
284
+ except Exception as e:
285
+ logger.error("Short answer grading failed for answer %d: %s", answer.id, e)
286
+ self._fallback_grade(answer, question)
287
+
288
+ # ═══════════════════════════════════════════════════════════
289
+ # DESCRIPTIVE — Multi-pass rubric evaluation
290
+ # ═══════════════════════════════════════════════════════════
291
+
292
+ def _grade_descriptive(self, answer: AnswerResponse, question: ExamQuestion):
293
+ if self.mode == "multi_pass":
294
+ self._grade_descriptive_multi_pass(answer, question)
295
+ else:
296
+ self._grade_descriptive_single(answer, question)
297
+
298
+ def _grade_descriptive_single(self, answer: AnswerResponse, question: ExamQuestion):
299
+ """Single-pass grading — faster but less accurate."""
300
+ try:
301
+ rubric = self._extract_rubric(question)
302
+
303
+ if rubric and settings.ENABLE_RUBRIC_GRADING:
304
+ prompt = RUBRIC_GRADING_PROMPT.format(
305
+ question=question.question_text,
306
+ correct_answer=question.correct_answer,
307
+ rubric=rubric,
308
+ student_answer=answer.student_answer,
309
+ max_score=question.marks,
310
+ )
311
+ else:
312
+ prompt = SIMPLE_GRADING_PROMPT.format(
313
+ question=question.question_text,
314
+ correct_answer=question.correct_answer,
315
+ student_answer=answer.student_answer,
316
+ max_score=question.marks,
317
+ )
318
+
319
+ raw = call_llm(prompt, GRADING_SYSTEM, temperature=0.1)
320
+ result = self._parse_grade(raw, question.marks)
321
+
322
+ answer.score = result["score"]
323
+ answer.is_correct = result["is_correct"]
324
+ answer.ai_feedback = result["feedback"]
325
+ answer.confidence_score = result["confidence"]
326
+
327
+ except Exception as e:
328
+ logger.error("Descriptive grading failed for answer %d: %s", answer.id, e)
329
+ self._fallback_grade(answer, question)
330
+
331
+ def _grade_descriptive_multi_pass(self, answer: AnswerResponse, question: ExamQuestion):
332
+ """
333
+ Multi-pass grading for maximum accuracy:
334
+ Pass 1: Initial rubric-based scoring
335
+ Pass 2: Verification — check for over/under scoring
336
+ Final: Weighted average with confidence calibration
337
+ """
338
+ try:
339
+ rubric = self._extract_rubric(question)
340
+
341
+ # ── PASS 1: Initial grading ──
342
+ if rubric and settings.ENABLE_RUBRIC_GRADING:
343
+ prompt1 = RUBRIC_GRADING_PROMPT.format(
344
+ question=question.question_text,
345
+ correct_answer=question.correct_answer,
346
+ rubric=rubric,
347
+ student_answer=answer.student_answer,
348
+ max_score=question.marks,
349
+ )
350
+ else:
351
+ prompt1 = SIMPLE_GRADING_PROMPT.format(
352
+ question=question.question_text,
353
+ correct_answer=question.correct_answer,
354
+ student_answer=answer.student_answer,
355
+ max_score=question.marks,
356
+ )
357
+
358
+ raw1 = call_llm(prompt1, GRADING_SYSTEM, temperature=0.1)
359
+ result1 = self._parse_grade(raw1, question.marks)
360
+
361
+ # ── PASS 2: Verification ──
362
+ prompt2 = VERIFICATION_PROMPT.format(
363
+ question=question.question_text,
364
+ correct_answer=question.correct_answer,
365
+ student_answer=answer.student_answer,
366
+ initial_score=result1["score"],
367
+ max_score=question.marks,
368
+ initial_feedback=result1["feedback"],
369
+ )
370
+
371
+ raw2 = call_llm(prompt2, GRADING_SYSTEM, temperature=0.1)
372
+ result2 = self._parse_verification(raw2, question.marks)
373
+
374
+ # ── COMBINE: Weighted average ──
375
+ pass1_score = result1["score"]
376
+ pass2_score = result2["adjusted_score"]
377
+ pass1_conf = result1["confidence"]
378
+ pass2_conf = result2["confidence"]
379
+
380
+ # Weight by confidence
381
+ total_conf = pass1_conf + pass2_conf
382
+ if total_conf > 0:
383
+ final_score = (pass1_score * pass1_conf + pass2_score * pass2_conf) / total_conf
384
+ else:
385
+ final_score = (pass1_score + pass2_score) / 2
386
+
387
+ final_score = round(min(final_score, question.marks), 2)
388
+ final_confidence = round((pass1_conf + pass2_conf) / 2, 3)
389
+
390
+ answer.score = final_score
391
+ answer.is_correct = final_score >= (question.marks * 0.7)
392
+ answer.confidence_score = final_confidence
393
+
394
+ # Build comprehensive feedback
395
+ feedback_parts = [result1["feedback"]]
396
+ if abs(pass1_score - pass2_score) > 0.5:
397
+ feedback_parts.append(
398
+ f"[Verification adjusted score from {pass1_score} to {pass2_score}: "
399
+ f"{result2.get('adjustment_reason', 'refinement')}]"
400
+ )
401
+ if final_confidence < self.confidence_threshold:
402
+ feedback_parts.append("[⚠ Low confidence — instructor review recommended]")
403
+
404
+ answer.ai_feedback = " ".join(feedback_parts)
405
+
406
+ logger.debug(
407
+ "Multi-pass grade: P1=%.2f (conf=%.2f) P2=%.2f (conf=%.2f) → Final=%.2f",
408
+ pass1_score, pass1_conf, pass2_score, pass2_conf, final_score,
409
+ )
410
+
411
+ except Exception as e:
412
+ logger.error("Multi-pass grading failed for answer %d: %s", answer.id, e)
413
+ # Try single pass as fallback
414
+ try:
415
+ self._grade_descriptive_single(answer, question)
416
+ except Exception:
417
+ self._fallback_grade(answer, question)
418
+
419
+ # ═══════════════════════════════════════════════════════════
420
+ # HELPERS
421
+ # ═══════════════════════════════════════════════════════════
422
+
423
+ def _extract_rubric(self, question: ExamQuestion) -> str:
424
+ """Extract rubric criteria from question explanation."""
425
+ if not question.explanation:
426
+ return ""
427
+ explanation = question.explanation
428
+ rubric_parts = []
429
+ if "Rubric:" in explanation:
430
+ rubric_section = explanation.split("Rubric:")[1].strip()
431
+ rubric_parts.append(rubric_section)
432
+ elif "Key terms:" in explanation:
433
+ terms_section = explanation.split("Key terms:")[1].strip()
434
+ rubric_parts.append(f"Must include these key terms: {terms_section}")
435
+ if not rubric_parts:
436
+ rubric_parts.append(f"Compare against model answer. Explanation: {explanation}")
437
+ return "\n".join(rubric_parts)
438
+
439
+ def _extract_key_terms(self, question: ExamQuestion) -> List[str]:
440
+ """Extract key terms from explanation."""
441
+ if not question.explanation:
442
+ return []
443
+ if "Key terms:" in question.explanation:
444
+ terms_str = question.explanation.split("Key terms:")[1].strip()
445
+ return [t.strip() for t in terms_str.split(",") if t.strip()]
446
+ return []
447
+
448
+ def _fallback_grade(self, answer: AnswerResponse, question: ExamQuestion):
449
+ """Keyword overlap scoring when LLM is unavailable."""
450
+ student_words = set(answer.student_answer.lower().split())
451
+ correct_words = set(question.correct_answer.lower().split())
452
+ # Remove common stop words
453
+ stop_words = {"the", "a", "an", "is", "are", "was", "were", "in", "on", "at",
454
+ "to", "for", "of", "and", "or", "but", "it", "this", "that", "with"}
455
+ student_words -= stop_words
456
+ correct_words -= stop_words
457
+
458
+ if not correct_words:
459
+ answer.score = 0.0
460
+ answer.is_correct = False
461
+ answer.ai_feedback = "Could not auto-grade. Manual review required."
462
+ answer.confidence_score = 0.0
463
+ return
464
+
465
+ overlap = len(student_words & correct_words) / len(correct_words)
466
+ answer.score = round(overlap * question.marks, 2)
467
+ answer.is_correct = overlap >= 0.7
468
+ answer.confidence_score = 0.2
469
+ answer.ai_feedback = (
470
+ f"Fallback scoring by keyword overlap ({overlap:.0%}). "
471
+ f"Matched: {', '.join(student_words & correct_words) or 'none'}. "
472
+ f"⚠ Manual review strongly recommended."
473
+ )
474
+
475
+ @staticmethod
476
+ def _parse_grade(text: str, max_score: float) -> dict:
477
+ """Parse grading JSON from LLM output."""
478
+ text = text.strip()
479
+ if text.startswith("```"):
480
+ lines = text.split("\n")
481
+ lines = [l for l in lines if not l.strip().startswith("```")]
482
+ text = "\n".join(lines).strip()
483
+ try:
484
+ start = text.find("{")
485
+ end = text.rfind("}")
486
+ if start != -1 and end != -1:
487
+ data = json.loads(text[start: end + 1])
488
+ return {
489
+ "score": min(float(data.get("score", 0)), max_score),
490
+ "is_correct": bool(data.get("is_correct", False)),
491
+ "feedback": str(data.get("feedback", "")),
492
+ "confidence": min(float(data.get("confidence", 0.5)), 1.0),
493
+ "key_terms_found": data.get("key_terms_found", []),
494
+ "key_terms_missing": data.get("key_terms_missing", []),
495
+ "criteria_met": data.get("criteria_met", []),
496
+ "criteria_missed": data.get("criteria_missed", []),
497
+ }
498
+ except (json.JSONDecodeError, ValueError, TypeError):
499
+ pass
500
+ return {
501
+ "score": 0.0,
502
+ "is_correct": False,
503
+ "feedback": "Grading response could not be parsed. Manual review needed.",
504
+ "confidence": 0.0,
505
+ }
506
+
507
+ @staticmethod
508
+ def _parse_verification(text: str, max_score: float) -> dict:
509
+ """Parse verification pass JSON."""
510
+ text = text.strip()
511
+ if text.startswith("```"):
512
+ lines = text.split("\n")
513
+ lines = [l for l in lines if not l.strip().startswith("```")]
514
+ text = "\n".join(lines).strip()
515
+ try:
516
+ start = text.find("{")
517
+ end = text.rfind("}")
518
+ if start != -1 and end != -1:
519
+ data = json.loads(text[start: end + 1])
520
+ return {
521
+ "adjusted_score": min(float(data.get("adjusted_score", 0)), max_score),
522
+ "adjustment_reason": str(data.get("adjustment_reason", "")),
523
+ "confidence": min(float(data.get("confidence", 0.5)), 1.0),
524
+ }
525
+ except (json.JSONDecodeError, ValueError, TypeError):
526
+ pass
527
+ return {
528
+ "adjusted_score": 0.0,
529
+ "adjustment_reason": "Could not parse verification",
530
+ "confidence": 0.0,
531
+ }
app/services/nvidia_embedder.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NVIDIA NIM clients: Embedder + Reranker + LLM.
3
+ """
4
+
5
+ import logging
6
+ import time
7
+ import requests
8
+ from typing import List, Optional, Dict, Any
9
+
10
+ from app.config import settings
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class NvidiaLLM:
16
+ """
17
+ NVIDIA NIM LLM client.
18
+ Model: nvidia/nemotron-3-nano-30b-a3b
19
+ """
20
+
21
+ def __init__(self):
22
+ self.api_key = settings.NVIDIA_API_KEY
23
+ self.base_url = settings.NVIDIA_BASE_URL.rstrip("/")
24
+ self.model = settings.NVIDIA_LLM_MODEL
25
+ logger.info("NvidiaLLM initialized: %s", self.model)
26
+
27
+ def chat(
28
+ self,
29
+ prompt: str,
30
+ system_prompt: str = "",
31
+ temperature: float | None = None,
32
+ max_tokens: int | None = None,
33
+ json_mode: bool = False,
34
+ ) -> str:
35
+ from openai import OpenAI
36
+
37
+ client = OpenAI(
38
+ api_key=self.api_key,
39
+ base_url=self.base_url,
40
+ )
41
+
42
+ messages = []
43
+ if system_prompt:
44
+ messages.append({"role": "system", "content": system_prompt})
45
+ messages.append({"role": "user", "content": prompt})
46
+
47
+ kwargs: Dict[str, Any] = {
48
+ "model": self.model,
49
+ "messages": messages,
50
+ "temperature": temperature if temperature is not None else settings.LLM_TEMPERATURE,
51
+ "max_tokens": max_tokens or settings.LLM_MAX_TOKENS,
52
+ }
53
+
54
+ if json_mode:
55
+ kwargs["response_format"] = {"type": "json_object"}
56
+
57
+ start = time.perf_counter()
58
+ try:
59
+ response = client.chat.completions.create(**kwargs)
60
+ elapsed = time.perf_counter() - start
61
+ result = response.choices[0].message.content or ""
62
+ logger.info(
63
+ "NVIDIA LLM: %d chars in %.2fs (model=%s, tokens=%d)",
64
+ len(result), elapsed, self.model,
65
+ max_tokens or settings.LLM_MAX_TOKENS,
66
+ )
67
+ return result
68
+ except Exception as e:
69
+ elapsed = time.perf_counter() - start
70
+ logger.error("NVIDIA LLM failed after %.2fs: %s", elapsed, str(e))
71
+ raise
72
+
73
+ def chat_with_retry(self, prompt: str, system_prompt: str = "", retries: int = 2, **kwargs) -> str:
74
+ last_error = None
75
+ for attempt in range(retries + 1):
76
+ try:
77
+ return self.chat(prompt, system_prompt, **kwargs)
78
+ except Exception as e:
79
+ last_error = e
80
+ if attempt < retries:
81
+ wait = 2 ** attempt
82
+ logger.warning("NVIDIA LLM attempt %d failed, retry in %ds: %s", attempt + 1, wait, str(e))
83
+ time.sleep(wait)
84
+ raise last_error
85
+
86
+
87
+ class NvidiaEmbedder:
88
+ """
89
+ NVIDIA NIM embedding client.
90
+ Model: nvidia/llama-3.2-nv-embedqa-1b-v2
91
+ """
92
+
93
+ def __init__(self):
94
+ self.api_key = settings.NVIDIA_API_KEY
95
+ self.base_url = settings.NVIDIA_BASE_URL.rstrip("/")
96
+ self.model = settings.NVIDIA_EMBED_MODEL
97
+ self.session = requests.Session()
98
+ self.session.headers.update({
99
+ "Authorization": f"Bearer {self.api_key}",
100
+ "Content-Type": "application/json",
101
+ "Accept": "application/json",
102
+ })
103
+ self._dimension: Optional[int] = None
104
+ logger.info("NvidiaEmbedder initialized: %s", self.model)
105
+
106
+ @property
107
+ def dimension(self) -> int:
108
+ if self._dimension is None:
109
+ test = self.embed(["dimension test"])
110
+ self._dimension = len(test[0])
111
+ logger.info("Embedding dimension: %d", self._dimension)
112
+ return self._dimension
113
+
114
+ def embed(self, texts: List[str], input_type: str = "passage") -> List[List[float]]:
115
+ if not texts:
116
+ return []
117
+
118
+ url = f"{self.base_url}/embeddings"
119
+ all_embeddings = []
120
+ batch_size = 50
121
+
122
+ for i in range(0, len(texts), batch_size):
123
+ batch = texts[i: i + batch_size]
124
+ batch = [t[:8000] if len(t) > 8000 else t for t in batch]
125
+
126
+ payload = {
127
+ "model": self.model,
128
+ "input": batch,
129
+ "input_type": input_type,
130
+ "encoding_format": "float",
131
+ }
132
+
133
+ try:
134
+ response = self.session.post(url, json=payload, timeout=120)
135
+ response.raise_for_status()
136
+ data = response.json()
137
+ sorted_data = sorted(data["data"], key=lambda x: x["index"])
138
+ all_embeddings.extend([item["embedding"] for item in sorted_data])
139
+ except requests.exceptions.HTTPError as e:
140
+ logger.error("NVIDIA Embed API: %s — %s", e.response.status_code, e.response.text[:500])
141
+ raise RuntimeError(f"NVIDIA Embed API failed: {e.response.status_code}") from e
142
+ except Exception as e:
143
+ logger.error("NVIDIA Embed failed: %s", str(e))
144
+ raise
145
+
146
+ return all_embeddings
147
+
148
+ def embed_query(self, text: str) -> List[float]:
149
+ return self.embed([text], input_type="query")[0]
150
+
151
+ def embed_documents(self, texts: List[str]) -> List[List[float]]:
152
+ return self.embed(texts, input_type="passage")
153
+
154
+
155
+ class NvidiaReranker:
156
+ """
157
+ NVIDIA NIM reranking client.
158
+
159
+ NVIDIA rerank API uses model-specific URLs:
160
+ https://ai.api.nvidia.com/v1/retrieval/{model_name}/reranking
161
+
162
+ NOT the generic /v1/ranking endpoint.
163
+ """
164
+
165
+ # Map model names to their correct API endpoint paths
166
+ RERANK_ENDPOINTS = {
167
+ "nvidia/llama-nemotron-rerank-1b-v2": "https://ai.api.nvidia.com/v1/retrieval/nvidia/llama-nemotron-rerank-1b-v2/reranking",
168
+ "nvidia/llama-3.2-nv-rerankqa-1b-v2": "https://ai.api.nvidia.com/v1/retrieval/nvidia/llama-3.2-nv-rerankqa-1b-v2/reranking",
169
+ "nvidia/llama-3.2-nemoretriever-500m-rerank-v2": "https://ai.api.nvidia.com/v1/retrieval/nvidia/llama-3.2-nemoretriever-500m-rerank-v2/reranking",
170
+ "nvidia/rerank-qa-mistral-4b": "https://ai.api.nvidia.com/v1/retrieval/nvidia/rerank-qa-mistral-4b/reranking",
171
+ "nvidia/nv-rerankqa-mistral-4b-v3": "https://ai.api.nvidia.com/v1/retrieval/nvidia/nv-rerankqa-mistral-4b-v3/reranking",
172
+ }
173
+
174
+ def __init__(self):
175
+ self.api_key = settings.NVIDIA_API_KEY
176
+ self.model = settings.NVIDIA_RERANK_MODEL
177
+ self.session = requests.Session()
178
+ self.session.headers.update({
179
+ "Authorization": f"Bearer {self.api_key}",
180
+ "Content-Type": "application/json",
181
+ "Accept": "application/json",
182
+ })
183
+
184
+ # Resolve the correct endpoint URL
185
+ if self.model in self.RERANK_ENDPOINTS:
186
+ self.url = self.RERANK_ENDPOINTS[self.model]
187
+ else:
188
+ # Build URL from model name
189
+ self.url = f"https://ai.api.nvidia.com/v1/retrieval/{self.model}/reranking"
190
+
191
+ logger.info("NvidiaReranker initialized: %s → %s", self.model, self.url)
192
+
193
+ def rerank(
194
+ self,
195
+ query: str,
196
+ passages: List[dict],
197
+ top_k: int = 5,
198
+ ) -> List[dict]:
199
+ """
200
+ Rerank passages by relevance to query.
201
+ """
202
+ if not passages or not query:
203
+ return passages[:top_k]
204
+
205
+ # Build the request payload
206
+ # NVIDIA rerank API expects: query.text + passages[].text
207
+ documents = []
208
+ for p in passages:
209
+ text = p.get("text", "")
210
+ if text:
211
+ documents.append(text[:4000])
212
+
213
+ if not documents:
214
+ return passages[:top_k]
215
+
216
+ payload = {
217
+ "model": self.model,
218
+ "query": {"text": query},
219
+ "passages": [{"text": doc} for doc in documents],
220
+ }
221
+
222
+ try:
223
+ response = self.session.post(self.url, json=payload, timeout=120)
224
+
225
+ # If model-specific URL fails, try alternative endpoint formats
226
+ if response.status_code == 404:
227
+ logger.warning("Rerank endpoint 404, trying alternative URL format...")
228
+ alt_url = f"{settings.NVIDIA_BASE_URL.rstrip('/')}/ranking"
229
+ payload_alt = {
230
+ "model": self.model,
231
+ "query": {"text": query},
232
+ "passages": [{"text": doc} for doc in documents],
233
+ "top_n": min(top_k, len(documents)),
234
+ }
235
+ response = self.session.post(alt_url, json=payload_alt, timeout=120)
236
+
237
+ if response.status_code == 404:
238
+ logger.warning("Rerank endpoint 404 on both URLs, trying OpenAI-compatible format...")
239
+ # Some NVIDIA models use a different payload format
240
+ alt_url2 = f"https://ai.api.nvidia.com/v1/retrieval/{self.model}/reranking"
241
+ payload_v2 = {
242
+ "model": self.model,
243
+ "query": {"text": query},
244
+ "passages": [{"text": doc} for doc in documents],
245
+ }
246
+ response = self.session.post(alt_url2, json=payload_v2, timeout=120)
247
+
248
+ response.raise_for_status()
249
+ data = response.json()
250
+
251
+ # Parse response — handle different response formats
252
+ rankings = data.get("rankings", [])
253
+
254
+ reranked = []
255
+ for rank in rankings:
256
+ idx = rank.get("index", 0)
257
+ if idx < len(passages):
258
+ passage = passages[idx].copy()
259
+ passage["rerank_score"] = rank.get("logit", rank.get("score", 0))
260
+ reranked.append(passage)
261
+
262
+ # Sort by score descending and take top_k
263
+ reranked.sort(key=lambda x: x.get("rerank_score", 0), reverse=True)
264
+ reranked = reranked[:top_k]
265
+
266
+ if reranked:
267
+ logger.info(
268
+ "Reranked %d → %d (scores: %.3f to %.3f)",
269
+ len(passages), len(reranked),
270
+ reranked[0].get("rerank_score", 0),
271
+ reranked[-1].get("rerank_score", 0),
272
+ )
273
+ return reranked
274
+
275
+ except requests.exceptions.HTTPError as e:
276
+ logger.warning(
277
+ "NVIDIA Rerank API failed (%s): %s — falling back to embedding-only results",
278
+ e.response.status_code,
279
+ e.response.text[:300],
280
+ )
281
+ return passages[:top_k]
282
+ except Exception as e:
283
+ logger.warning("NVIDIA Rerank failed: %s — falling back to embedding-only results", str(e))
284
+ return passages[:top_k]
app/services/question_generator.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG + LLM question generation with robust JSON parsing.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ from typing import List, Optional
9
+
10
+ from sqlalchemy import func
11
+ from sqlalchemy.orm import Session
12
+
13
+ from app.models.question import ExamQuestion
14
+ from app.services.rag_pipeline import RAGPipeline
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ SYSTEM_PROMPT = """You are an expert exam question generator for academic assessments.
19
+
20
+ STRICT RULES:
21
+ 1. Use ONLY the provided course context.
22
+ 2. Every question must be answerable from the context.
23
+ 3. Return ONLY a valid JSON array — no markdown fences, no extra text.
24
+ 4. Keep model answers CONCISE — max 3 sentences for short answer, max 5 sentences for descriptive.
25
+ 5. Each question must have ONE clear correct answer."""
26
+
27
+ MCQ_PROMPT = """Generate exactly {num} MCQ questions from the context.
28
+ Difficulty: {difficulty}
29
+ {topic_line}
30
+
31
+ Each question: 4 options (A,B,C,D), one correct answer, brief explanation.
32
+
33
+ Return JSON array:
34
+ [{{"question_text":"...","options":{{"A":"...","B":"...","C":"...","D":"..."}},"correct_answer":"A","explanation":"...","difficulty":"{difficulty}"}}]"""
35
+
36
+ SHORT_ANSWER_PROMPT = """Generate exactly {num} short-answer questions from the context.
37
+ Difficulty: {difficulty}
38
+ {topic_line}
39
+
40
+ Keep model answers to 1-2 sentences maximum.
41
+
42
+ Return JSON array:
43
+ [{{"question_text":"...","correct_answer":"1-2 sentence answer","explanation":"Why this is correct","difficulty":"{difficulty}"}}]"""
44
+
45
+ DESCRIPTIVE_PROMPT = """Generate exactly {num} essay questions from the context.
46
+ Difficulty: {difficulty}
47
+ {topic_line}
48
+
49
+ Keep model answers to 3-5 sentences maximum. Be concise.
50
+
51
+ Return JSON array:
52
+ [{{"question_text":"...","correct_answer":"3-5 sentence model answer","explanation":"Key evaluation points","difficulty":"{difficulty}"}}]"""
53
+
54
+
55
+ class QuestionGeneratorService:
56
+ def __init__(self, db: Session):
57
+ self.db = db
58
+ self.rag = RAGPipeline()
59
+
60
+ def generate(
61
+ self,
62
+ course_id: int,
63
+ exam_id: int,
64
+ num_questions: int = 5,
65
+ question_type: str = "mcq",
66
+ difficulty: str = "medium",
67
+ topic: Optional[str] = None,
68
+ ) -> List[ExamQuestion]:
69
+
70
+ topic_line = f"Focus on: {topic}" if topic else ""
71
+ search_query = topic or "key concepts important topics"
72
+
73
+ passages = self.rag.retrieve_context(course_id, search_query, top_k=10)
74
+ if not passages:
75
+ raise ValueError("No indexed content found. Upload and index course files first.")
76
+
77
+ if question_type == "mixed":
78
+ mcq_n = max(1, num_questions // 3)
79
+ short_n = max(1, num_questions // 3)
80
+ desc_n = num_questions - mcq_n - short_n
81
+ questions = []
82
+ if mcq_n > 0:
83
+ questions += self._gen_type(passages, "mcq", mcq_n, difficulty, topic_line, exam_id)
84
+ if short_n > 0:
85
+ questions += self._gen_type(passages, "short_answer", short_n, difficulty, topic_line, exam_id)
86
+ if desc_n > 0:
87
+ questions += self._gen_type(passages, "descriptive", desc_n, difficulty, topic_line, exam_id)
88
+ return questions
89
+ else:
90
+ return self._gen_type(passages, question_type, num_questions, difficulty, topic_line, exam_id)
91
+
92
+ def _gen_type(self, passages, qtype, num, difficulty, topic_line, exam_id) -> List[ExamQuestion]:
93
+ templates = {
94
+ "mcq": MCQ_PROMPT,
95
+ "short_answer": SHORT_ANSWER_PROMPT,
96
+ "descriptive": DESCRIPTIVE_PROMPT,
97
+ }
98
+ template = templates.get(qtype, MCQ_PROMPT)
99
+ user_prompt = template.format(num=num, difficulty=difficulty, topic_line=topic_line)
100
+
101
+ # Use higher max_tokens for descriptive to avoid truncation
102
+ token_limits = {
103
+ "mcq": 4096,
104
+ "short_answer": 4096,
105
+ "descriptive": 8192,
106
+ }
107
+
108
+ raw = self.rag.generate_with_context(
109
+ passages, user_prompt, SYSTEM_PROMPT,
110
+ temperature=0.3,
111
+ max_tokens=token_limits.get(qtype, 4096),
112
+ )
113
+
114
+ questions_data = self._parse_json(raw)
115
+ if not questions_data:
116
+ # Retry once with explicit JSON instruction
117
+ retry_prompt = user_prompt + "\n\nIMPORTANT: Return ONLY the JSON array. No markdown. No ```json. Just the raw [ ... ] array."
118
+ raw = self.rag.generate_with_context(
119
+ passages, retry_prompt, SYSTEM_PROMPT,
120
+ temperature=0.2,
121
+ max_tokens=token_limits.get(qtype, 4096),
122
+ )
123
+ questions_data = self._parse_json(raw)
124
+
125
+ if not questions_data:
126
+ raise ValueError(f"Failed to parse LLM response for {qtype} questions. The AI response was not valid JSON.")
127
+
128
+ max_idx = (
129
+ self.db.query(func.max(ExamQuestion.order_index))
130
+ .filter(ExamQuestion.exam_id == exam_id)
131
+ .scalar()
132
+ ) or 0
133
+
134
+ marks_map = {"mcq": 1.0, "short_answer": 3.0, "descriptive": 5.0}
135
+ created: List[ExamQuestion] = []
136
+
137
+ for i, qd in enumerate(questions_data[:num]):
138
+ if not isinstance(qd, dict):
139
+ continue
140
+ question_text = qd.get("question_text", "").strip()
141
+ correct_answer = qd.get("correct_answer", "").strip()
142
+ if not question_text or not correct_answer:
143
+ continue
144
+
145
+ q = ExamQuestion(
146
+ exam_id=exam_id,
147
+ question_text=question_text,
148
+ question_type=qtype,
149
+ options=qd.get("options"),
150
+ correct_answer=correct_answer,
151
+ marks=marks_map.get(qtype, 1.0),
152
+ explanation=qd.get("explanation", ""),
153
+ difficulty=qd.get("difficulty", difficulty),
154
+ order_index=max_idx + i + 1,
155
+ )
156
+ self.db.add(q)
157
+ created.append(q)
158
+
159
+ self.db.commit()
160
+ for q in created:
161
+ self.db.refresh(q)
162
+
163
+ logger.info("Generated %d %s questions for exam %d", len(created), qtype, exam_id)
164
+ return created
165
+
166
+ @staticmethod
167
+ def _parse_json(text: str) -> list:
168
+ """
169
+ Robust JSON extraction from LLM output.
170
+ Handles: markdown fences, truncated JSON, mixed text.
171
+ """
172
+ if not text or not text.strip():
173
+ return []
174
+
175
+ text = text.strip()
176
+
177
+ # Step 1: Remove markdown code fences
178
+ text = re.sub(r'^```(?:json)?\s*\n?', '', text, flags=re.MULTILINE)
179
+ text = re.sub(r'\n?```\s*$', '', text, flags=re.MULTILINE)
180
+ text = text.strip()
181
+
182
+ # Step 2: Try direct parse
183
+ try:
184
+ data = json.loads(text)
185
+ if isinstance(data, list):
186
+ return data
187
+ if isinstance(data, dict):
188
+ return [data]
189
+ except json.JSONDecodeError:
190
+ pass
191
+
192
+ # Step 3: Find JSON array in text
193
+ start = text.find("[")
194
+ end = text.rfind("]")
195
+ if start != -1 and end != -1 and end > start:
196
+ json_str = text[start: end + 1]
197
+ try:
198
+ data = json.loads(json_str)
199
+ if isinstance(data, list):
200
+ return data
201
+ except json.JSONDecodeError:
202
+ pass
203
+
204
+ # Step 4: Try to fix truncated JSON (response cut off mid-object)
205
+ if start != -1:
206
+ json_str = text[start:]
207
+
208
+ # If array is not closed, try to close it
209
+ if "]" not in json_str:
210
+ # Find the last complete object (ends with })
211
+ last_brace = json_str.rfind("}")
212
+ if last_brace != -1:
213
+ json_str = json_str[:last_brace + 1] + "]"
214
+ try:
215
+ data = json.loads(json_str)
216
+ if isinstance(data, list):
217
+ logger.warning("Recovered %d items from truncated JSON", len(data))
218
+ return data
219
+ except json.JSONDecodeError:
220
+ pass
221
+
222
+ # Try removing the last incomplete object
223
+ # Find all complete objects by splitting on },{
224
+ try:
225
+ # Remove outer brackets
226
+ inner = json_str.strip()
227
+ if inner.startswith("["):
228
+ inner = inner[1:]
229
+ if inner.endswith("]"):
230
+ inner = inner[:-1]
231
+
232
+ # Split into potential objects
233
+ objects = []
234
+ depth = 0
235
+ current = ""
236
+ for char in inner:
237
+ current += char
238
+ if char == "{":
239
+ depth += 1
240
+ elif char == "}":
241
+ depth -= 1
242
+ if depth == 0:
243
+ # Try parsing this object
244
+ obj_str = current.strip().strip(",").strip()
245
+ try:
246
+ obj = json.loads(obj_str)
247
+ objects.append(obj)
248
+ except json.JSONDecodeError:
249
+ pass
250
+ current = ""
251
+
252
+ if objects:
253
+ logger.warning("Recovered %d items by parsing individual objects", len(objects))
254
+ return objects
255
+ except Exception:
256
+ pass
257
+
258
+ # Step 5: Try to find individual JSON objects
259
+ objects = []
260
+ for match in re.finditer(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', text):
261
+ try:
262
+ obj = json.loads(match.group())
263
+ if "question_text" in obj:
264
+ objects.append(obj)
265
+ except json.JSONDecodeError:
266
+ continue
267
+
268
+ if objects:
269
+ logger.warning("Recovered %d questions by regex extraction", len(objects))
270
+ return objects
271
+
272
+ logger.error("JSON parse completely failed. Response preview: %s", text[:500])
273
+ return []
app/services/rag_pipeline.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG pipeline — with max_tokens passthrough for long responses.
3
+ """
4
+
5
+ import logging
6
+ from typing import List, Dict, Any
7
+
8
+ from app.config import settings
9
+ from app.services.vector_store import VectorStoreService
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _get_llm_response(
15
+ prompt: str,
16
+ system_prompt: str = "",
17
+ temperature: float | None = None,
18
+ max_tokens: int | None = None,
19
+ json_mode: bool = False,
20
+ provider_override: str | None = None,
21
+ ) -> str:
22
+ provider = (provider_override or settings.LLM_PROVIDER).lower()
23
+
24
+ if provider == "nvidia":
25
+ try:
26
+ from app.services.nvidia_embedder import NvidiaLLM
27
+ llm = NvidiaLLM()
28
+ return llm.chat(
29
+ prompt=prompt,
30
+ system_prompt=system_prompt,
31
+ temperature=temperature,
32
+ max_tokens=max_tokens,
33
+ json_mode=json_mode,
34
+ )
35
+ except Exception as e:
36
+ logger.warning("NVIDIA LLM failed, trying fallback: %s", str(e))
37
+ fallback = settings.FALLBACK_LLM_PROVIDER.lower()
38
+ if fallback and fallback != "nvidia":
39
+ return _get_llm_response(
40
+ prompt, system_prompt, temperature, max_tokens,
41
+ json_mode=False, provider_override=fallback,
42
+ )
43
+ raise
44
+
45
+ elif provider == "openai":
46
+ from openai import OpenAI
47
+ client = OpenAI(api_key=settings.OPENAI_API_KEY)
48
+ messages = []
49
+ if system_prompt:
50
+ messages.append({"role": "system", "content": system_prompt})
51
+ messages.append({"role": "user", "content": prompt})
52
+ kwargs = {
53
+ "model": settings.OPENAI_MODEL,
54
+ "messages": messages,
55
+ "temperature": temperature if temperature is not None else settings.LLM_TEMPERATURE,
56
+ "max_tokens": max_tokens or settings.LLM_MAX_TOKENS,
57
+ }
58
+ response = client.chat.completions.create(**kwargs)
59
+ return response.choices[0].message.content or ""
60
+
61
+ elif provider == "gemini":
62
+ import google.generativeai as genai
63
+ genai.configure(api_key=settings.GOOGLE_API_KEY)
64
+ model = genai.GenerativeModel(settings.GEMINI_MODEL)
65
+ full_prompt = f"{system_prompt}\n\n{prompt}" if system_prompt else prompt
66
+ response = model.generate_content(full_prompt)
67
+ return response.text or ""
68
+
69
+ else:
70
+ raise ValueError(f"Unsupported LLM provider: {provider}")
71
+
72
+
73
+ class RAGPipeline:
74
+ def __init__(self):
75
+ self.vs = VectorStoreService()
76
+
77
+ def retrieve_context(
78
+ self,
79
+ course_id: int,
80
+ query: str,
81
+ top_k: int = 8,
82
+ use_reranker: bool | None = None,
83
+ ) -> List[Dict[str, Any]]:
84
+ collection_name = f"course_{course_id}"
85
+ should_rerank = use_reranker if use_reranker is not None else settings.USE_RERANKER
86
+
87
+ if should_rerank:
88
+ results = self.vs.search_with_rerank(
89
+ collection_name, query,
90
+ initial_top_k=settings.RETRIEVAL_TOP_K,
91
+ final_top_k=settings.RERANK_TOP_K,
92
+ )
93
+ else:
94
+ results = self.vs.search(collection_name, query, top_k=top_k)
95
+
96
+ logger.info("Retrieved %d passages for course %d (reranked=%s)", len(results), course_id, should_rerank)
97
+ return results
98
+
99
+ def generate_with_context(
100
+ self,
101
+ context_passages: List[Dict[str, Any]],
102
+ user_prompt: str,
103
+ system_prompt: str = "",
104
+ temperature: float | None = None,
105
+ json_mode: bool = False,
106
+ max_tokens: int | None = None,
107
+ ) -> str:
108
+ context_parts = []
109
+ for i, p in enumerate(context_passages):
110
+ text = p.get("text", "")
111
+ if not text:
112
+ continue
113
+ score_info = ""
114
+ if "rerank_score" in p:
115
+ score_info = f" [relevance: {p['rerank_score']:.3f}]"
116
+ context_parts.append(f"[Passage {i + 1}{score_info}]\n{text}")
117
+
118
+ context_text = "\n\n---\n\n".join(context_parts)
119
+ full_prompt = (
120
+ f"### CONTEXT (course material — use ONLY this):\n"
121
+ f"{context_text}\n\n"
122
+ f"### INSTRUCTION:\n{user_prompt}"
123
+ )
124
+ return _get_llm_response(
125
+ full_prompt, system_prompt,
126
+ temperature=temperature,
127
+ max_tokens=max_tokens,
128
+ json_mode=json_mode,
129
+ )
130
+
131
+ def query(
132
+ self,
133
+ course_id: int,
134
+ user_prompt: str,
135
+ system_prompt: str = "",
136
+ top_k: int = 8,
137
+ ) -> str:
138
+ passages = self.retrieve_context(course_id, user_prompt, top_k)
139
+ if not passages:
140
+ logger.warning("No passages found for course %d", course_id)
141
+ return _get_llm_response(user_prompt, system_prompt)
142
+ return self.generate_with_context(passages, user_prompt, system_prompt)
143
+
144
+
145
+ def call_llm(
146
+ prompt: str,
147
+ system_prompt: str = "",
148
+ temperature: float | None = None,
149
+ json_mode: bool = False,
150
+ max_tokens: int | None = None,
151
+ ) -> str:
152
+ return _get_llm_response(
153
+ prompt, system_prompt,
154
+ temperature=temperature,
155
+ max_tokens=max_tokens,
156
+ json_mode=json_mode,
157
+ )