""" Item model for questions with CTT and IRT parameters. Represents individual questions with both classical test theory (CTT) and item response theory (IRT) parameters. """ from datetime import datetime from typing import Literal, Union from sqlalchemy import ( Boolean, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base class Item(Base): """ Item model representing individual questions. Supports both CTT (p, bobot, category) and IRT (b, se) parameters. Tracks AI generation metadata and calibration status. Attributes: id: Primary key tryout_id: Tryout identifier website_id: Website identifier slot: Question position in tryout level: Difficulty level (mudah, sedang, sulit) stem: Question text options: JSON array of answer options correct_answer: Correct option (A, B, C, D) explanation: Answer explanation ctt_p: CTT difficulty (proportion correct) ctt_bobot: CTT weight (1 - p) ctt_category: CTT difficulty category irt_b: IRT difficulty parameter [-3, +3] irt_se: IRT standard error calibrated: Calibration status calibration_sample_size: Sample size for calibration generated_by: Generation source (manual, ai) ai_model: AI model used (if generated by AI) basis_item_id: Original item ID (for AI variants) created_at: Record creation timestamp updated_at: Record update timestamp tryout: Tryout relationship user_answers: User responses to this item """ __tablename__ = "items" # Primary key id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) # Foreign keys tryout_id: Mapped[str] = mapped_column( String(255), nullable=False, index=True, comment="Tryout identifier" ) website_id: Mapped[int] = mapped_column( ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False, index=True, comment="Website identifier", ) # Position and difficulty slot: Mapped[int] = mapped_column( Integer, nullable=False, comment="Question position in tryout" ) level: Mapped[Literal["mudah", "sedang", "sulit"]] = mapped_column( String(50), nullable=False, comment="Difficulty level" ) # Question content stem: Mapped[str] = mapped_column(Text, nullable=False, comment="Question text") options: Mapped[dict] = mapped_column( JSON, nullable=False, comment="JSON object with options (e.g., {\"A\": \"option1\", \"B\": \"option2\"})", ) correct_answer: Mapped[str] = mapped_column( String(10), nullable=False, comment="Correct option (A, B, C, D)" ) explanation: Mapped[Union[str, None]] = mapped_column( Text, nullable=True, comment="Answer explanation" ) # CTT parameters ctt_p: Mapped[Union[float, None]] = mapped_column( Float, nullable=True, comment="CTT difficulty (proportion correct)", ) ctt_bobot: Mapped[Union[float, None]] = mapped_column( Float, nullable=True, comment="CTT weight (1 - p)", ) ctt_category: Mapped[Union[Literal["mudah", "sedang", "sulit"], None]] = mapped_column( String(50), nullable=True, comment="CTT difficulty category", ) # IRT parameters (1PL Rasch model) irt_b: Mapped[Union[float, None]] = mapped_column( Float, nullable=True, comment="IRT difficulty parameter [-3, +3]", ) irt_se: Mapped[Union[float, None]] = mapped_column( Float, nullable=True, comment="IRT standard error", ) # Calibration status calibrated: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, comment="Calibration status" ) calibration_sample_size: Mapped[int] = mapped_column( Integer, nullable=False, default=0, comment="Sample size for calibration", ) # AI generation metadata generated_by: Mapped[Literal["manual", "ai"]] = mapped_column( String(50), nullable=False, default="manual", comment="Generation source", ) ai_model: Mapped[Union[str, None]] = mapped_column( String(255), nullable=True, comment="AI model used (if generated by AI)", ) basis_item_id: Mapped[Union[int, None]] = mapped_column( ForeignKey("items.id", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, index=True, comment="Original item ID (for AI variants)", ) # Timestamps created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default="NOW()" ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default="NOW()", onupdate="NOW()", ) # Relationships tryout: Mapped["Tryout"] = relationship( "Tryout", back_populates="items", lazy="selectin" ) user_answers: Mapped[list["UserAnswer"]] = relationship( "UserAnswer", back_populates="item", lazy="selectin", cascade="all, delete-orphan" ) basis_item: Mapped[Union["Item", None]] = relationship( "Item", remote_side=[id], back_populates="variants", lazy="selectin", single_parent=True, ) variants: Mapped[list["Item"]] = relationship( "Item", back_populates="basis_item", lazy="selectin", cascade="all, delete-orphan", ) # Constraints and indexes __table_args__ = ( Index( "ix_items_tryout_id_website_id_slot", "tryout_id", "website_id", "slot", "level", unique=True, ), Index("ix_items_calibrated", "calibrated"), Index("ix_items_basis_item_id", "basis_item_id"), # IRT b parameter constraint [-3, +3] CheckConstraint( "irt_b IS NULL OR (irt_b >= -3 AND irt_b <= 3)", "ck_irt_b_range", ), # CTT p constraint [0, 1] CheckConstraint( "ctt_p IS NULL OR (ctt_p >= 0 AND ctt_p <= 1)", "ck_ctt_p_range", ), # CTT bobot constraint [0, 1] CheckConstraint( "ctt_bobot IS NULL OR (ctt_bobot >= 0 AND ctt_bobot <= 1)", "ck_ctt_bobot_range", ), # Slot must be positive CheckConstraint("slot > 0", "ck_slot_positive"), ) def __repr__(self) -> str: return f""