Files
yellow-bank-soal/app/api/v1/session.py
Dwindi Ramadhana cf193d7ea0 first commit
2026-03-21 23:32:59 +07:00

389 lines
12 KiB
Python

"""
Session API endpoints for CAT item selection.
Provides endpoints for:
- GET /api/v1/session/{session_id}/next_item - Get next question
- POST /api/v1/admin/cat/test - Admin playground for testing CAT
"""
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Item, Session, Tryout
from app.services.cat_selection import (
CATSelectionError,
get_next_item,
should_terminate,
simulate_cat_selection,
update_theta,
)
# Default SE threshold for termination
DEFAULT_SE_THRESHOLD = 0.5
# Session router for student-facing endpoints
router = APIRouter()
# Admin router for admin-only endpoints
admin_router = APIRouter()
# ============== Request/Response Models ==============
class NextItemResponse(BaseModel):
"""Response for next item endpoint."""
status: Literal["item", "completed"] = "item"
item_id: Optional[int] = None
stem: Optional[str] = None
options: Optional[dict] = None
slot: Optional[int] = None
level: Optional[str] = None
selection_method: Optional[str] = None
reason: Optional[str] = None
current_theta: Optional[float] = None
current_se: Optional[float] = None
items_answered: Optional[int] = None
class SubmitAnswerRequest(BaseModel):
"""Request for submitting an answer."""
item_id: int = Field(..., description="Item ID being answered")
response: str = Field(..., description="User's answer (A, B, C, D)")
time_spent: int = Field(default=0, ge=0, description="Time spent on question (seconds)")
class SubmitAnswerResponse(BaseModel):
"""Response for submitting an answer."""
is_correct: bool
correct_answer: str
explanation: Optional[str] = None
theta: Optional[float] = None
theta_se: Optional[float] = None
class CATTestRequest(BaseModel):
"""Request for admin CAT test endpoint."""
tryout_id: str = Field(..., description="Tryout identifier")
website_id: int = Field(..., description="Website identifier")
initial_theta: float = Field(default=0.0, ge=-3.0, le=3.0, description="Initial theta value")
selection_mode: Literal["fixed", "adaptive", "hybrid"] = Field(
default="adaptive", description="Selection mode"
)
max_items: int = Field(default=15, ge=1, le=100, description="Maximum items to simulate")
se_threshold: float = Field(
default=0.5, ge=0.1, le=3.0, description="SE threshold for termination"
)
hybrid_transition_slot: int = Field(
default=10, ge=1, description="Slot to transition in hybrid mode"
)
class CATTestResponse(BaseModel):
"""Response for admin CAT test endpoint."""
tryout_id: str
website_id: int
initial_theta: float
selection_mode: str
total_items: int
final_theta: float
final_se: float
se_threshold_met: bool
items: list
# ============== Session Endpoints ==============
@router.get(
"/{session_id}/next_item",
response_model=NextItemResponse,
summary="Get next item for session",
description="Returns the next question for a session based on the tryout's selection mode."
)
async def get_next_item_endpoint(
session_id: str,
db: AsyncSession = Depends(get_db)
) -> NextItemResponse:
"""
Get the next item for a session.
Validates session exists and is not completed.
Gets Tryout config (scoring_mode, selection_mode, max_items).
Calls appropriate selection function based on selection_mode.
Returns item or completion status.
"""
# Get session
session_query = select(Session).where(Session.session_id == session_id)
session_result = await db.execute(session_query)
session = session_result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {session_id} not found"
)
if session.is_completed:
return NextItemResponse(
status="completed",
reason="Session already completed"
)
# Get tryout config
tryout_query = select(Tryout).where(
Tryout.tryout_id == session.tryout_id,
Tryout.website_id == session.website_id
)
tryout_result = await db.execute(tryout_query)
tryout = tryout_result.scalar_one_or_none()
if not tryout:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tryout {session.tryout_id} not found"
)
# Check termination conditions
termination = await should_terminate(
db,
session_id,
max_items=None, # Will be set from tryout config if needed
se_threshold=DEFAULT_SE_THRESHOLD
)
if termination.should_terminate:
return NextItemResponse(
status="completed",
reason=termination.reason,
current_theta=session.theta,
current_se=session.theta_se,
items_answered=termination.items_answered
)
# Get next item based on selection mode
try:
result = await get_next_item(
db,
session_id,
selection_mode=tryout.selection_mode,
hybrid_transition_slot=tryout.hybrid_transition_slot or 10,
ai_generation_enabled=tryout.ai_generation_enabled
)
except CATSelectionError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
if result.item is None:
return NextItemResponse(
status="completed",
reason=result.reason,
current_theta=session.theta,
current_se=session.theta_se,
items_answered=termination.items_answered
)
item = result.item
return NextItemResponse(
status="item",
item_id=item.id,
stem=item.stem,
options=item.options,
slot=item.slot,
level=item.level,
selection_method=result.selection_method,
reason=result.reason,
current_theta=session.theta,
current_se=session.theta_se,
items_answered=termination.items_answered
)
@router.post(
"/{session_id}/submit_answer",
response_model=SubmitAnswerResponse,
summary="Submit answer for item",
description="Submit an answer for an item and update theta estimate."
)
async def submit_answer_endpoint(
session_id: str,
request: SubmitAnswerRequest,
db: AsyncSession = Depends(get_db)
) -> SubmitAnswerResponse:
"""
Submit an answer for an item.
Validates session and item.
Checks correctness.
Updates theta estimate.
Records response time.
"""
# Get session
session_query = select(Session).where(Session.session_id == session_id)
session_result = await db.execute(session_query)
session = session_result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {session_id} not found"
)
if session.is_completed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session already completed"
)
# Get item
item_query = select(Item).where(Item.id == request.item_id)
item_result = await db.execute(item_query)
item = item_result.scalar_one_or_none()
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item {request.item_id} not found"
)
# Check correctness
is_correct = request.response.upper() == item.correct_answer.upper()
# Update theta
theta, theta_se = await update_theta(db, session_id, request.item_id, is_correct)
# Create user answer record
from app.models import UserAnswer
user_answer = UserAnswer(
session_id=session_id,
wp_user_id=session.wp_user_id,
website_id=session.website_id,
tryout_id=session.tryout_id,
item_id=request.item_id,
response=request.response.upper(),
is_correct=is_correct,
time_spent=request.time_spent,
scoring_mode_used=session.scoring_mode_used,
bobot_earned=item.ctt_bobot if is_correct and item.ctt_bobot else 0.0
)
db.add(user_answer)
await db.commit()
return SubmitAnswerResponse(
is_correct=is_correct,
correct_answer=item.correct_answer,
explanation=item.explanation,
theta=theta,
theta_se=theta_se
)
# ============== Admin Endpoints ==============
@admin_router.post(
"/cat/test",
response_model=CATTestResponse,
summary="Test CAT selection algorithm",
description="Admin playground for testing adaptive selection behavior."
)
async def test_cat_endpoint(
request: CATTestRequest,
db: AsyncSession = Depends(get_db)
) -> CATTestResponse:
"""
Test CAT selection algorithm.
Simulates CAT selection for a tryout and returns
the sequence of selected items with theta progression.
"""
# Verify tryout exists
tryout_query = select(Tryout).where(
Tryout.tryout_id == request.tryout_id,
Tryout.website_id == request.website_id
)
tryout_result = await db.execute(tryout_query)
tryout = tryout_result.scalar_one_or_none()
if not tryout:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tryout {request.tryout_id} not found for website {request.website_id}"
)
# Run simulation
result = await simulate_cat_selection(
db,
tryout_id=request.tryout_id,
website_id=request.website_id,
initial_theta=request.initial_theta,
selection_mode=request.selection_mode,
max_items=request.max_items,
se_threshold=request.se_threshold,
hybrid_transition_slot=request.hybrid_transition_slot
)
if "error" in result:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result["error"]
)
return CATTestResponse(**result)
@admin_router.get(
"/session/{session_id}/status",
summary="Get session status",
description="Get detailed session status including theta and SE."
)
async def get_session_status_endpoint(
session_id: str,
db: AsyncSession = Depends(get_db)
) -> dict:
"""
Get session status for admin monitoring.
"""
# Get session
session_query = select(Session).where(Session.session_id == session_id)
session_result = await db.execute(session_query)
session = session_result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {session_id} not found"
)
# Count answers
from sqlalchemy import func
from app.models import UserAnswer
count_query = select(func.count(UserAnswer.id)).where(
UserAnswer.session_id == session_id
)
count_result = await db.execute(count_query)
items_answered = count_result.scalar() or 0
return {
"session_id": session.session_id,
"wp_user_id": session.wp_user_id,
"tryout_id": session.tryout_id,
"is_completed": session.is_completed,
"theta": session.theta,
"theta_se": session.theta_se,
"items_answered": items_answered,
"scoring_mode_used": session.scoring_mode_used,
"NM": session.NM,
"NN": session.NN,
"start_time": session.start_time.isoformat() if session.start_time else None,
"end_time": session.end_time.isoformat() if session.end_time else None
}