feat: add Script Builder service and API endpoints

- Script Builder service with language-specific system prompts (PowerShell, Bash, Python)
- AI-powered script generation with code block extraction and filename detection
- Context window management (last 20 messages) and session message limits
- REST API: CRUD sessions, send messages, save to Script Library
- Rate limiting on message endpoint (10/min), max 5 concurrent sessions per user
- Registered script_build action in AI model tier routing (standard tier)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-03-21 16:58:26 -04:00
parent 35c0c67da3
commit 28f8200b36
4 changed files with 521 additions and 0 deletions

View File

@@ -0,0 +1,151 @@
"""Script Builder API endpoints."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.rate_limit import limiter
from app.api.deps import get_current_active_user
from app.models.user import User
from app.schemas.script_builder import (
ScriptBuilderCreateRequest,
ScriptBuilderMessageRequest,
ScriptBuilderMessageResponse,
ScriptBuilderSessionDetail,
ScriptBuilderSessionSummary,
SaveToLibraryRequest,
)
from app.schemas.script_template import ScriptTemplateDetail
from app.services import script_builder_service
router = APIRouter(prefix="/scripts/builder", tags=["script-builder"])
MAX_SESSIONS_PER_USER = 5
@router.post("/sessions", response_model=ScriptBuilderSessionDetail, status_code=201)
async def create_session(
data: ScriptBuilderCreateRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptBuilderSessionDetail:
"""Start a new Script Builder session."""
# Enforce max concurrent sessions
count = await script_builder_service.count_user_sessions(db, current_user.id)
if count >= MAX_SESSIONS_PER_USER:
raise HTTPException(
status_code=400,
detail=f"Maximum of {MAX_SESSIONS_PER_USER} builder sessions allowed. Delete an old session first.",
)
session = await script_builder_service.create_session(
db=db,
user_id=current_user.id,
team_id=current_user.team_id,
language=data.language,
)
await db.commit()
return ScriptBuilderSessionDetail.model_validate(session)
@router.get("/sessions", response_model=list[ScriptBuilderSessionSummary])
async def list_sessions(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
limit: int = 20,
offset: int = 0,
) -> list[ScriptBuilderSessionSummary]:
"""List user's recent builder sessions (lightweight, no messages)."""
sessions = await script_builder_service.list_sessions(
db=db, user_id=current_user.id, limit=limit, offset=offset
)
return [ScriptBuilderSessionSummary.model_validate(s) for s in sessions]
@router.get("/sessions/{session_id}", response_model=ScriptBuilderSessionDetail)
async def get_session(
session_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptBuilderSessionDetail:
"""Get full session detail with message history."""
session = await script_builder_service.get_session(db, session_id, current_user.id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return ScriptBuilderSessionDetail.model_validate(session)
@router.post(
"/sessions/{session_id}/messages",
response_model=ScriptBuilderMessageResponse,
)
@limiter.limit("10/minute")
async def send_message(
request: Request,
session_id: UUID,
data: ScriptBuilderMessageRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptBuilderMessageResponse:
"""Send a message and get AI-generated script response."""
session = await script_builder_service.get_session(db, session_id, current_user.id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
try:
response = await script_builder_service.send_message(db, session, data.content)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await db.commit()
return response
@router.delete("/sessions/{session_id}", status_code=204)
async def delete_session(
session_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
"""Delete a builder session."""
deleted = await script_builder_service.delete_session(db, session_id, current_user.id)
if not deleted:
raise HTTPException(status_code=404, detail="Session not found")
await db.commit()
@router.post(
"/sessions/{session_id}/save",
response_model=ScriptTemplateDetail,
status_code=201,
)
async def save_to_library(
session_id: UUID,
data: SaveToLibraryRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptTemplateDetail:
"""Save the latest generated script to the Script Library."""
session = await script_builder_service.get_session(db, session_id, current_user.id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
try:
template = await script_builder_service.save_to_library(
db=db,
session=session,
name=data.name,
description=data.description,
category_id=data.category_id,
share_with_team=data.share_with_team,
user_id=current_user.id,
team_id=current_user.team_id,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await db.commit()
await db.refresh(template)
return ScriptTemplateDetail.model_validate(template)