The google-genai SDK uses `client.aio.models.generate_content()` for async calls, not `client.models.generate_content_async()` which doesn't exist. Also removes the temporary /ai/provider-debug endpoint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
440 lines
13 KiB
Python
440 lines
13 KiB
Python
"""AI Flow Builder wizard endpoints.
|
|
|
|
4-stage wizard:
|
|
POST /ai/start — Stage 1: create conversation with metadata
|
|
POST /ai/scaffold — Stage 2: AI suggests branches
|
|
POST /ai/branch-detail — Stage 3: AI generates detail for one branch
|
|
POST /ai/assemble — Stage 4: assemble branches into tree (no AI)
|
|
GET /ai/quota — quota status
|
|
"""
|
|
import logging
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.rate_limit import limiter
|
|
|
|
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
|
from app.core.config import settings
|
|
from app.core.ai_conversation_store import (
|
|
create_conversation,
|
|
get_conversation,
|
|
update_conversation,
|
|
)
|
|
from app.core.ai_quota_service import check_ai_quota, record_ai_usage, get_user_plan
|
|
from app.core.ai_tree_generator_service import (
|
|
scaffold_branches,
|
|
generate_branch_detail,
|
|
assemble_tree,
|
|
)
|
|
from app.models.user import User
|
|
from app.schemas.ai_builder import (
|
|
AIStartRequest,
|
|
AIStartResponse,
|
|
AIScaffoldRequest,
|
|
AIScaffoldResponse,
|
|
AIBranchDetailRequest,
|
|
AIBranchDetailResponse,
|
|
AIAssembleRequest,
|
|
AIAssembleResponse,
|
|
AIQuotaStatusResponse,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/ai", tags=["ai-builder"])
|
|
|
|
|
|
def _require_ai_enabled() -> None:
|
|
"""Raise 503 if AI is not configured."""
|
|
if not settings.ai_enabled:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="AI flow builder is not configured. Set GOOGLE_AI_API_KEY or ANTHROPIC_API_KEY.",
|
|
)
|
|
|
|
@router.get("/quota", response_model=AIQuotaStatusResponse)
|
|
async def get_quota(
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
):
|
|
"""Get current user's AI quota status."""
|
|
if not settings.ai_enabled:
|
|
return AIQuotaStatusResponse(
|
|
plan="free",
|
|
monthly_used=0,
|
|
monthly_limit=None,
|
|
monthly_reset_at="",
|
|
daily_used=0,
|
|
daily_limit=None,
|
|
daily_reset_at="",
|
|
allowed=False,
|
|
ai_enabled=False,
|
|
)
|
|
|
|
_, quota_status = await check_ai_quota(
|
|
user_id=current_user.id,
|
|
account_id=current_user.account_id,
|
|
db=db,
|
|
billing_anchor=current_user.ai_billing_cycle_anchor_at,
|
|
is_super_admin=current_user.is_super_admin,
|
|
)
|
|
return AIQuotaStatusResponse(
|
|
**quota_status,
|
|
ai_enabled=True,
|
|
)
|
|
|
|
|
|
@router.post("/start", response_model=AIStartResponse, status_code=201)
|
|
@limiter.limit("10/minute")
|
|
async def start_conversation(
|
|
request: Request,
|
|
data: AIStartRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
):
|
|
"""Stage 1: Create a new AI wizard conversation with foundation metadata."""
|
|
_require_ai_enabled()
|
|
|
|
# Check daily quota (anti-abuse) — super admins bypass
|
|
allowed, quota_status = await check_ai_quota(
|
|
user_id=current_user.id,
|
|
account_id=current_user.account_id,
|
|
db=db,
|
|
billing_anchor=current_user.ai_billing_cycle_anchor_at,
|
|
is_super_admin=current_user.is_super_admin,
|
|
)
|
|
if not allowed:
|
|
reset_key = (
|
|
"daily_reset_at"
|
|
if quota_status.get("deny_reason") == "daily"
|
|
else "monthly_reset_at"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail={
|
|
"message": f"AI build limit exceeded ({quota_status['deny_reason']})",
|
|
"reset_at": quota_status.get(reset_key),
|
|
"quota": quota_status,
|
|
},
|
|
)
|
|
|
|
wizard_state = {
|
|
"flow_type": data.flow_type,
|
|
"name": data.name,
|
|
"description": data.description,
|
|
"environment_tags": data.environment_tags,
|
|
"category_id": str(data.category_id) if data.category_id else None,
|
|
}
|
|
|
|
conversation = await create_conversation(
|
|
user_id=current_user.id,
|
|
account_id=current_user.account_id,
|
|
wizard_state=wizard_state,
|
|
db=db,
|
|
)
|
|
await db.commit()
|
|
|
|
return AIStartResponse(
|
|
conversation_id=conversation.id,
|
|
status=conversation.status,
|
|
)
|
|
|
|
|
|
@router.post("/scaffold", response_model=AIScaffoldResponse)
|
|
@limiter.limit("10/minute")
|
|
async def scaffold(
|
|
request: Request,
|
|
data: AIScaffoldRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
):
|
|
"""Stage 2: AI suggests top-level branches."""
|
|
_require_ai_enabled()
|
|
|
|
conversation = await get_conversation(
|
|
data.conversation_id, current_user.id, db
|
|
)
|
|
|
|
# Check per-flow call limit
|
|
if conversation.question_rounds >= settings.AI_MAX_CALLS_PER_FLOW:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail="Maximum AI calls per flow exceeded",
|
|
)
|
|
|
|
plan = await get_user_plan(current_user.account_id, db)
|
|
|
|
try:
|
|
branches, input_tokens, output_tokens, cost = await scaffold_branches(
|
|
conversation.wizard_state,
|
|
)
|
|
except ValueError as e:
|
|
await record_ai_usage(
|
|
user_id=current_user.id,
|
|
account_id=current_user.account_id,
|
|
conversation_id=conversation.id,
|
|
generation_type="scaffold",
|
|
tier=plan,
|
|
input_tokens=0,
|
|
output_tokens=0,
|
|
estimated_cost=0,
|
|
succeeded=False,
|
|
counts_toward_quota=False,
|
|
error_code="invalid_output",
|
|
extra_data={"error": str(e)},
|
|
db=db,
|
|
)
|
|
await db.commit()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"AI returned invalid output: {e}",
|
|
)
|
|
except Exception as e:
|
|
logger.exception("AI scaffold failed: %s: %s", type(e).__name__, e)
|
|
await record_ai_usage(
|
|
user_id=current_user.id,
|
|
account_id=current_user.account_id,
|
|
conversation_id=conversation.id,
|
|
generation_type="scaffold",
|
|
tier=plan,
|
|
input_tokens=0,
|
|
output_tokens=0,
|
|
estimated_cost=0,
|
|
succeeded=False,
|
|
counts_toward_quota=False,
|
|
error_code=type(e).__name__,
|
|
extra_data={"error": str(e)},
|
|
db=db,
|
|
)
|
|
await db.commit()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"AI provider error ({type(e).__name__}). Please try again.",
|
|
)
|
|
|
|
# Record successful usage
|
|
await record_ai_usage(
|
|
user_id=current_user.id,
|
|
account_id=current_user.account_id,
|
|
conversation_id=conversation.id,
|
|
generation_type="scaffold",
|
|
tier=plan,
|
|
input_tokens=input_tokens,
|
|
output_tokens=output_tokens,
|
|
estimated_cost=cost,
|
|
succeeded=True,
|
|
counts_toward_quota=False,
|
|
error_code=None,
|
|
extra_data=None,
|
|
db=db,
|
|
)
|
|
|
|
# Update conversation state
|
|
wizard_state = dict(conversation.wizard_state)
|
|
wizard_state["branches"] = branches
|
|
await update_conversation(
|
|
conversation.id,
|
|
current_user.id,
|
|
{
|
|
"status": "scaffolding",
|
|
"wizard_state": wizard_state,
|
|
"question_rounds": conversation.question_rounds + 1,
|
|
},
|
|
db,
|
|
)
|
|
await db.commit()
|
|
|
|
return AIScaffoldResponse(
|
|
conversation_id=conversation.id,
|
|
branches=branches,
|
|
status="scaffolding",
|
|
)
|
|
|
|
|
|
@router.post("/branch-detail", response_model=AIBranchDetailResponse)
|
|
@limiter.limit("10/minute")
|
|
async def branch_detail(
|
|
request: Request,
|
|
data: AIBranchDetailRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
):
|
|
"""Stage 3: AI generates detailed nodes for one branch."""
|
|
_require_ai_enabled()
|
|
|
|
conversation = await get_conversation(
|
|
data.conversation_id, current_user.id, db
|
|
)
|
|
|
|
if conversation.question_rounds >= settings.AI_MAX_CALLS_PER_FLOW:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail="Maximum AI calls per flow exceeded",
|
|
)
|
|
|
|
wizard_state = conversation.wizard_state
|
|
existing_branches = [
|
|
b.get("name", "") for b in wizard_state.get("branches", [])
|
|
]
|
|
|
|
plan = await get_user_plan(current_user.account_id, db)
|
|
|
|
try:
|
|
branch_tree, input_tokens, output_tokens, cost = (
|
|
await generate_branch_detail(
|
|
wizard_state,
|
|
data.branch_name,
|
|
existing_branches,
|
|
)
|
|
)
|
|
except ValueError as e:
|
|
await record_ai_usage(
|
|
user_id=current_user.id,
|
|
account_id=current_user.account_id,
|
|
conversation_id=conversation.id,
|
|
generation_type="branch_detail",
|
|
tier=plan,
|
|
input_tokens=0,
|
|
output_tokens=0,
|
|
estimated_cost=0,
|
|
succeeded=False,
|
|
counts_toward_quota=False,
|
|
error_code="invalid_output",
|
|
extra_data={"error": str(e), "branch_name": data.branch_name},
|
|
db=db,
|
|
)
|
|
await db.commit()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"AI returned invalid output: {e}",
|
|
)
|
|
except Exception as e:
|
|
logger.exception("AI branch_detail failed: %s: %s", type(e).__name__, e)
|
|
await record_ai_usage(
|
|
user_id=current_user.id,
|
|
account_id=current_user.account_id,
|
|
conversation_id=conversation.id,
|
|
generation_type="branch_detail",
|
|
tier=plan,
|
|
input_tokens=0,
|
|
output_tokens=0,
|
|
estimated_cost=0,
|
|
succeeded=False,
|
|
counts_toward_quota=False,
|
|
error_code=type(e).__name__,
|
|
extra_data={"error": str(e), "branch_name": data.branch_name},
|
|
db=db,
|
|
)
|
|
await db.commit()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"AI provider error ({type(e).__name__}). Please try again.",
|
|
)
|
|
|
|
# Record successful usage
|
|
await record_ai_usage(
|
|
user_id=current_user.id,
|
|
account_id=current_user.account_id,
|
|
conversation_id=conversation.id,
|
|
generation_type="branch_detail",
|
|
tier=plan,
|
|
input_tokens=input_tokens,
|
|
output_tokens=output_tokens,
|
|
estimated_cost=cost,
|
|
succeeded=True,
|
|
counts_toward_quota=False,
|
|
error_code=None,
|
|
extra_data={"branch_name": data.branch_name},
|
|
db=db,
|
|
)
|
|
|
|
# Update conversation
|
|
await update_conversation(
|
|
conversation.id,
|
|
current_user.id,
|
|
{
|
|
"status": "detailing",
|
|
"question_rounds": conversation.question_rounds + 1,
|
|
},
|
|
db,
|
|
)
|
|
await db.commit()
|
|
|
|
return AIBranchDetailResponse(
|
|
conversation_id=conversation.id,
|
|
branch_name=data.branch_name,
|
|
steps=branch_tree,
|
|
status="detailing",
|
|
)
|
|
|
|
|
|
@router.post("/assemble", response_model=AIAssembleResponse)
|
|
@limiter.limit("10/minute")
|
|
async def assemble(
|
|
request: Request,
|
|
data: AIAssembleRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
):
|
|
"""Stage 4: Assemble selected branches into a complete tree (no AI calls)."""
|
|
conversation = await get_conversation(
|
|
data.conversation_id, current_user.id, db
|
|
)
|
|
|
|
wizard_state = conversation.wizard_state
|
|
branches_for_assembly = [b.model_dump() for b in data.selected_branches]
|
|
|
|
try:
|
|
tree_structure, name, description, stats = assemble_tree(
|
|
wizard_state, branches_for_assembly
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=str(e),
|
|
)
|
|
|
|
# Record quota-consuming usage on successful assembly
|
|
plan = await get_user_plan(current_user.account_id, db)
|
|
await record_ai_usage(
|
|
user_id=current_user.id,
|
|
account_id=current_user.account_id,
|
|
conversation_id=conversation.id,
|
|
generation_type="tree",
|
|
tier=plan,
|
|
input_tokens=0,
|
|
output_tokens=0,
|
|
estimated_cost=0,
|
|
succeeded=True,
|
|
counts_toward_quota=True,
|
|
error_code=None,
|
|
extra_data={"stats": stats},
|
|
db=db,
|
|
)
|
|
|
|
# Update conversation with assembled tree
|
|
await update_conversation(
|
|
conversation.id,
|
|
current_user.id,
|
|
{
|
|
"status": "completed",
|
|
"generated_tree": tree_structure,
|
|
},
|
|
db,
|
|
)
|
|
await db.commit()
|
|
|
|
return AIAssembleResponse(
|
|
tree_structure=tree_structure,
|
|
suggested_name=name,
|
|
suggested_description=description,
|
|
summary=stats,
|
|
status="completed",
|
|
)
|