first commit
This commit is contained in:
292
app/routers/ai.py
Normal file
292
app/routers/ai.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
AI Generation Router.
|
||||
|
||||
Admin endpoints for AI question generation playground.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.item import Item
|
||||
from app.schemas.ai import (
|
||||
AIGeneratePreviewRequest,
|
||||
AIGeneratePreviewResponse,
|
||||
AISaveRequest,
|
||||
AISaveResponse,
|
||||
AIStatsResponse,
|
||||
)
|
||||
from app.services.ai_generation import (
|
||||
generate_question,
|
||||
get_ai_stats,
|
||||
save_ai_question,
|
||||
validate_ai_model,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/ai", tags=["admin", "ai-generation"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate-preview",
|
||||
response_model=AIGeneratePreviewResponse,
|
||||
summary="Preview AI-generated question",
|
||||
description="""
|
||||
Generate a question preview using AI without saving to database.
|
||||
|
||||
This is an admin playground endpoint for testing AI generation quality.
|
||||
Admins can retry unlimited times until satisfied with the result.
|
||||
|
||||
Requirements:
|
||||
- basis_item_id must reference an existing item at 'sedang' level
|
||||
- target_level must be 'mudah' or 'sulit'
|
||||
- ai_model must be a supported OpenRouter model
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "Question generated successfully (preview mode)"},
|
||||
400: {"description": "Invalid request (wrong level, unsupported model)"},
|
||||
404: {"description": "Basis item not found"},
|
||||
500: {"description": "AI generation failed"},
|
||||
},
|
||||
)
|
||||
async def generate_preview(
|
||||
request: AIGeneratePreviewRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> AIGeneratePreviewResponse:
|
||||
"""
|
||||
Generate AI question preview (no database save).
|
||||
|
||||
- **basis_item_id**: ID of the sedang-level question to base generation on
|
||||
- **target_level**: Target difficulty (mudah/sulit)
|
||||
- **ai_model**: OpenRouter model to use (default: qwen/qwen-2.5-coder-32b-instruct)
|
||||
"""
|
||||
# Validate AI model
|
||||
if not validate_ai_model(request.ai_model):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported AI model: {request.ai_model}. "
|
||||
f"Supported models: qwen/qwen-2.5-coder-32b-instruct, meta-llama/llama-3.3-70b-instruct",
|
||||
)
|
||||
|
||||
# Fetch basis item
|
||||
result = await db.execute(
|
||||
select(Item).where(Item.id == request.basis_item_id)
|
||||
)
|
||||
basis_item = result.scalar_one_or_none()
|
||||
|
||||
if not basis_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Basis item not found: {request.basis_item_id}",
|
||||
)
|
||||
|
||||
# Validate basis item is sedang level
|
||||
if basis_item.level != "sedang":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Basis item must be 'sedang' level, got: {basis_item.level}",
|
||||
)
|
||||
|
||||
# Generate question
|
||||
try:
|
||||
generated = await generate_question(
|
||||
basis_item=basis_item,
|
||||
target_level=request.target_level,
|
||||
ai_model=request.ai_model,
|
||||
)
|
||||
|
||||
if not generated:
|
||||
return AIGeneratePreviewResponse(
|
||||
success=False,
|
||||
error="AI generation failed. Please check logs or try again.",
|
||||
ai_model=request.ai_model,
|
||||
basis_item_id=request.basis_item_id,
|
||||
target_level=request.target_level,
|
||||
)
|
||||
|
||||
return AIGeneratePreviewResponse(
|
||||
success=True,
|
||||
stem=generated.stem,
|
||||
options=generated.options,
|
||||
correct=generated.correct,
|
||||
explanation=generated.explanation,
|
||||
ai_model=request.ai_model,
|
||||
basis_item_id=request.basis_item_id,
|
||||
target_level=request.target_level,
|
||||
cached=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AI preview generation failed: {e}")
|
||||
return AIGeneratePreviewResponse(
|
||||
success=False,
|
||||
error=f"AI generation error: {str(e)}",
|
||||
ai_model=request.ai_model,
|
||||
basis_item_id=request.basis_item_id,
|
||||
target_level=request.target_level,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate-save",
|
||||
response_model=AISaveResponse,
|
||||
summary="Save AI-generated question",
|
||||
description="""
|
||||
Save an AI-generated question to the database.
|
||||
|
||||
This endpoint creates a new Item record with:
|
||||
- generated_by='ai'
|
||||
- ai_model from request
|
||||
- basis_item_id linking to original question
|
||||
- calibrated=False (will be calculated later)
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "Question saved successfully"},
|
||||
400: {"description": "Invalid request data"},
|
||||
404: {"description": "Basis item or tryout not found"},
|
||||
409: {"description": "Item already exists at this slot/level"},
|
||||
500: {"description": "Database save failed"},
|
||||
},
|
||||
)
|
||||
async def generate_save(
|
||||
request: AISaveRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> AISaveResponse:
|
||||
"""
|
||||
Save AI-generated question to database.
|
||||
|
||||
- **stem**: Question text
|
||||
- **options**: Dict with A, B, C, D options
|
||||
- **correct**: Correct answer (A/B/C/D)
|
||||
- **explanation**: Answer explanation (optional)
|
||||
- **tryout_id**: Tryout identifier
|
||||
- **website_id**: Website identifier
|
||||
- **basis_item_id**: Original item ID this was generated from
|
||||
- **slot**: Question slot position
|
||||
- **level**: Difficulty level
|
||||
- **ai_model**: AI model used for generation
|
||||
"""
|
||||
# Verify basis item exists
|
||||
basis_result = await db.execute(
|
||||
select(Item).where(Item.id == request.basis_item_id)
|
||||
)
|
||||
basis_item = basis_result.scalar_one_or_none()
|
||||
|
||||
if not basis_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Basis item not found: {request.basis_item_id}",
|
||||
)
|
||||
|
||||
# Check for duplicate (same tryout, website, slot, level)
|
||||
existing_result = await db.execute(
|
||||
select(Item).where(
|
||||
and_(
|
||||
Item.tryout_id == request.tryout_id,
|
||||
Item.website_id == request.website_id,
|
||||
Item.slot == request.slot,
|
||||
Item.level == request.level,
|
||||
)
|
||||
)
|
||||
)
|
||||
existing = existing_result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Item already exists at slot={request.slot}, level={request.level} "
|
||||
f"for tryout={request.tryout_id}",
|
||||
)
|
||||
|
||||
# Create GeneratedQuestion from request
|
||||
from app.schemas.ai import GeneratedQuestion
|
||||
|
||||
generated_data = GeneratedQuestion(
|
||||
stem=request.stem,
|
||||
options=request.options,
|
||||
correct=request.correct,
|
||||
explanation=request.explanation,
|
||||
)
|
||||
|
||||
# Save to database
|
||||
item_id = await save_ai_question(
|
||||
generated_data=generated_data,
|
||||
tryout_id=request.tryout_id,
|
||||
website_id=request.website_id,
|
||||
basis_item_id=request.basis_item_id,
|
||||
slot=request.slot,
|
||||
level=request.level,
|
||||
ai_model=request.ai_model,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if not item_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to save AI-generated question",
|
||||
)
|
||||
|
||||
return AISaveResponse(
|
||||
success=True,
|
||||
item_id=item_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=AIStatsResponse,
|
||||
summary="Get AI generation statistics",
|
||||
description="""
|
||||
Get statistics about AI-generated questions.
|
||||
|
||||
Returns:
|
||||
- Total AI-generated items count
|
||||
- Items count by model
|
||||
- Cache hit rate (placeholder)
|
||||
""",
|
||||
)
|
||||
async def get_stats(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> AIStatsResponse:
|
||||
"""
|
||||
Get AI generation statistics.
|
||||
"""
|
||||
stats = await get_ai_stats(db)
|
||||
|
||||
return AIStatsResponse(
|
||||
total_ai_items=stats["total_ai_items"],
|
||||
items_by_model=stats["items_by_model"],
|
||||
cache_hit_rate=stats["cache_hit_rate"],
|
||||
total_cache_hits=stats["total_cache_hits"],
|
||||
total_requests=stats["total_requests"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/models",
|
||||
summary="List supported AI models",
|
||||
description="Returns list of supported AI models for question generation.",
|
||||
)
|
||||
async def list_models() -> dict:
|
||||
"""
|
||||
List supported AI models.
|
||||
"""
|
||||
return {
|
||||
"models": [
|
||||
{
|
||||
"id": "qwen/qwen-2.5-coder-32b-instruct",
|
||||
"name": "Qwen 2.5 Coder 32B",
|
||||
"description": "Fast and efficient model for question generation",
|
||||
},
|
||||
{
|
||||
"id": "meta-llama/llama-3.3-70b-instruct",
|
||||
"name": "Llama 3.3 70B",
|
||||
"description": "High-quality model with better reasoning",
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user