""" 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 }