"""SQLAlchemy ORM models matching the architecture diagram. Tables: users instructor / TA accounts (simple RBAC) exams one row per exam instance rubrics versioned per exam (immutable; updates create new rows) papers one row per uploaded student PDF crops one row per cropped answer region gradings one row per grading pass (5 per crop) grading_aggregates median / max / std-dev per crop reviews TA decisions (approve / override / flag) plagiarism_flags pairs of crops above similarity threshold audit_log immutable decision trail with model + prompt + rubric versions """ from __future__ import annotations from datetime import datetime from sqlalchemy import ( create_engine, Column, Integer, String, Float, DateTime, ForeignKey, JSON, Text, Boolean, UniqueConstraint ) from sqlalchemy.orm import declarative_base, relationship, sessionmaker, Session from .config import settings # Engine + session ------------------------------------------------------------ connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} engine = create_engine(settings.database_url, connect_args=connect_args, future=True) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) Base = declarative_base() def get_db() -> Session: """FastAPI dependency that yields a database session and closes it on exit.""" db = SessionLocal() try: yield db finally: db.close() def init_db() -> None: """Create all tables. Idempotent — safe to call on every boot.""" Base.metadata.create_all(bind=engine) # Tables --------------------------------------------------------------------- class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True) email = Column(String, unique=True, nullable=False) name = Column(String, nullable=False) role = Column(String, nullable=False) # 'instructor' | 'ta' created_at = Column(DateTime, default=datetime.utcnow) class Exam(Base): __tablename__ = "exams" id = Column(Integer, primary_key=True) session_id = Column(String, nullable=False, default="public", index=True) # Session Isolation title = Column(String, nullable=False) created_by = Column(Integer, ForeignKey("users.id"), nullable=True) created_at = Column(DateTime, default=datetime.utcnow) rubrics = relationship("Rubric", back_populates="exam", cascade="all, delete-orphan") papers = relationship("Paper", back_populates="exam", cascade="all, delete-orphan") class Rubric(Base): __tablename__ = "rubrics" id = Column(Integer, primary_key=True) exam_id = Column(Integer, ForeignKey("exams.id"), nullable=False) version = Column(String, nullable=False) # e.g. 'v1', 'v2' title = Column(String, nullable=False) question_text = Column(Text, nullable=False) max_marks = Column(Float, nullable=False) course_instructions = Column(Text, nullable=False) criteria = Column(JSON, nullable=False) # list of dicts; see schemas.py created_at = Column(DateTime, default=datetime.utcnow) exam = relationship("Exam", back_populates="rubrics") class Paper(Base): __tablename__ = "papers" id = Column(Integer, primary_key=True) exam_id = Column(Integer, ForeignKey("exams.id"), nullable=False) student_anon_id = Column(String, nullable=False) # e.g. 'STU-0001'; never the real ID source_pdf_path = Column(String, nullable=False) uploaded_at = Column(DateTime, default=datetime.utcnow) exam = relationship("Exam", back_populates="papers") crops = relationship("Crop", back_populates="paper", cascade="all, delete-orphan") class Crop(Base): __tablename__ = "crops" id = Column(Integer, primary_key=True) paper_id = Column(Integer, ForeignKey("papers.id"), nullable=False) question_id = Column(String, nullable=False) # e.g. 'Q1', 'Q2a' rubric_id = Column(Integer, ForeignKey("rubrics.id"), nullable=False) image_path = Column(String, nullable=False) # raw crop anonymized_path = Column(String, nullable=False) # post-anonymization bbox = Column(JSON, nullable=True) # {x,y,w,h} on source page created_at = Column(DateTime, default=datetime.utcnow) paper = relationship("Paper", back_populates="crops") gradings = relationship("Grading", back_populates="crop", cascade="all, delete-orphan") class Grading(Base): """One row per LLM grading pass. NUM_PASSES rows expected per crop.""" __tablename__ = "gradings" id = Column(Integer, primary_key=True) crop_id = Column(Integer, ForeignKey("crops.id"), nullable=False) rubric_id = Column(Integer, ForeignKey("rubrics.id"), nullable=False) pass_num = Column(Integer, nullable=False) score = Column(Float, nullable=False) max_score = Column(Float, nullable=False) per_criterion = Column(JSON, nullable=False) # list[{name, awarded, max, reasoning}] justification = Column(Text, nullable=False) transcript = Column(Text, nullable=False) flags = Column(JSON, nullable=False, default=list) critic_passed = Column(Boolean, nullable=False, default=True) critic_feedback = Column(Text, nullable=True) model_version = Column(String, nullable=False) prompt_version = Column(String, nullable=False) created_at = Column(DateTime, default=datetime.utcnow) crop = relationship("Crop", back_populates="gradings") class GradingAggregate(Base): """Aggregated scores across passes; one row per crop.""" __tablename__ = "grading_aggregates" crop_id = Column(Integer, ForeignKey("crops.id"), primary_key=True) median = Column(Float, nullable=False) max_score = Column(Float, nullable=False) min_score = Column(Float, nullable=False) std_dev = Column(Float, nullable=False) n_passes = Column(Integer, nullable=False) computed_at = Column(DateTime, default=datetime.utcnow) class Review(Base): __tablename__ = "reviews" id = Column(Integer, primary_key=True) crop_id = Column(Integer, ForeignKey("crops.id"), nullable=False) reviewer_id = Column(Integer, ForeignKey("users.id"), nullable=True) action = Column(String, nullable=False) # approve | override | flag ai_score = Column(Float, nullable=False) final_score = Column(Float, nullable=False) notes = Column(Text, nullable=True) created_at = Column(DateTime, default=datetime.utcnow) class PlagiarismFlag(Base): __tablename__ = "plagiarism_flags" id = Column(Integer, primary_key=True) crop_a_id = Column(Integer, ForeignKey("crops.id"), nullable=False) crop_b_id = Column(Integer, ForeignKey("crops.id"), nullable=False) similarity = Column(Float, nullable=False) flagged_at = Column(DateTime, default=datetime.utcnow) __table_args__ = (UniqueConstraint("crop_a_id", "crop_b_id", name="uniq_pair"),) class AuditLog(Base): """Immutable trail of every state change. Append-only.""" __tablename__ = "audit_log" id = Column(Integer, primary_key=True) entity_type = Column(String, nullable=False) # 'grading', 'review', etc entity_id = Column(Integer, nullable=False) action = Column(String, nullable=False) actor_id = Column(Integer, ForeignKey("users.id"), nullable=True) before = Column(JSON, nullable=True) after = Column(JSON, nullable=True) rubric_version = Column(String, nullable=True) prompt_version = Column(String, nullable=True) model_version = Column(String, nullable=True) created_at = Column(DateTime, default=datetime.utcnow)