223 lines
6.7 KiB
Python
223 lines
6.7 KiB
Python
"""
|
|
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"<Item(id={self.id}, slot={self.slot}, level={self.level})>"
|