first commit
This commit is contained in:
25
app/api/v1/__init__.py
Normal file
25
app/api/v1/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
API v1 Router configuration.
|
||||
|
||||
Defines all API v1 endpoints and their prefixes.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import session
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Include session endpoints
|
||||
api_router.include_router(
|
||||
session.router,
|
||||
prefix="/session",
|
||||
tags=["session"]
|
||||
)
|
||||
|
||||
# Include admin endpoints
|
||||
api_router.include_router(
|
||||
session.admin_router,
|
||||
prefix="/admin",
|
||||
tags=["admin"]
|
||||
)
|
||||
388
app/api/v1/session.py
Normal file
388
app/api/v1/session.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user