389 lines
12 KiB
Python
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
|
|
}
|