first commit
This commit is contained in:
222
app/models/item.py
Normal file
222
app/models/item.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
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})>"
|
||||
Reference in New Issue
Block a user