Spaces:
Configuration error
Configuration error
Commit ·
419bd6e
0
Parent(s):
FYP Clean Final Push
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +9 -0
- Dockerfile +19 -0
- README.md +70 -0
- alembic.ini +36 -0
- alembic/env.py +43 -0
- app/__init__.py +0 -0
- app/config.py +75 -0
- app/database.py +33 -0
- app/dependencies.py +54 -0
- app/middleware/__init__.py +0 -0
- app/middleware/activity_logger.py +30 -0
- app/models/__init__.py +23 -0
- app/models/activity_log.py +22 -0
- app/models/contact.py +27 -0
- app/models/content.py +37 -0
- app/models/course.py +39 -0
- app/models/exam.py +47 -0
- app/models/question.py +26 -0
- app/models/submission.py +44 -0
- app/models/user.py +30 -0
- app/routers/__init__.py +0 -0
- app/routers/admin.py +93 -0
- app/routers/analytics.py +50 -0
- app/routers/auth.py +46 -0
- app/routers/contact.py +48 -0
- app/routers/content.py +157 -0
- app/routers/courses.py +276 -0
- app/routers/exams.py +156 -0
- app/routers/grading.py +152 -0
- app/routers/questions.py +99 -0
- app/routers/submissions.py +241 -0
- app/routers/users.py +74 -0
- app/schemas/__init__.py +0 -0
- app/schemas/analytics.py +46 -0
- app/schemas/contact.py +32 -0
- app/schemas/content.py +37 -0
- app/schemas/course.py +46 -0
- app/schemas/exam.py +55 -0
- app/schemas/question.py +65 -0
- app/schemas/submission.py +59 -0
- app/schemas/user.py +47 -0
- app/services/__init__.py +0 -0
- app/services/analytics_service.py +179 -0
- app/services/auth_service.py +84 -0
- app/services/contact_service.py +40 -0
- app/services/content_ingestion.py +54 -0
- app/services/grading_service.py +531 -0
- app/services/nvidia_embedder.py +284 -0
- app/services/question_generator.py +273 -0
- 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 |
+
)
|