Implements Phase 1 of the FlowPilot-First pivot — the core AI session experience where engineers describe a problem and FlowPilot guides them through structured diagnosis with selectable options, free-text escape hatches, and auto-generated documentation on resolution. Backend: AISession + AISessionStep models, FlowPilot Engine (LLM orchestration with structured JSON output), Flow Matching Engine v1 (semantic + keyword + recency scoring), 8 API endpoints with auth, rate limiting, and AI quota enforcement. Frontend: Intake screen, conversational session view with sidebar, step cards with options/actions/resolution suggestions, resolve/escalate modals, documentation view with rating, session history integration, and /pilot route with sidebar navigation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
412 lines
13 KiB
Python
412 lines
13 KiB
Python
"""FlowPilot AI session endpoints.
|
|
|
|
CRUD and interaction endpoints for AI-powered troubleshooting sessions:
|
|
POST /ai-sessions — Start a new session
|
|
POST /ai-sessions/{id}/respond — Submit step response, get next step
|
|
POST /ai-sessions/{id}/resolve — Resolve the session
|
|
POST /ai-sessions/{id}/escalate — Escalate the session
|
|
GET /ai-sessions — List user's sessions (paginated)
|
|
GET /ai-sessions/{id} — Get session detail with all steps
|
|
GET /ai-sessions/{id}/documentation — Get auto-generated documentation
|
|
POST /ai-sessions/{id}/rate — Submit post-session rating
|
|
"""
|
|
import logging
|
|
from typing import Annotated, Optional
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
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_quota_service import check_ai_quota, record_ai_usage, get_user_plan
|
|
from app.models.user import User
|
|
from app.models.ai_session import AISession
|
|
from app.schemas.ai_session import (
|
|
AISessionCreateRequest,
|
|
AISessionCreateResponse,
|
|
StepResponseRequest,
|
|
StepResponseResponse,
|
|
ResolveSessionRequest,
|
|
EscalateSessionRequest,
|
|
SessionCloseResponse,
|
|
SessionDocumentation,
|
|
RateSessionRequest,
|
|
AISessionSummary,
|
|
AISessionDetail,
|
|
AISessionStepResponse,
|
|
StepOptionSchema,
|
|
)
|
|
from app.services import flowpilot_engine
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/ai-sessions", tags=["ai-sessions"])
|
|
|
|
|
|
def _require_ai_enabled() -> None:
|
|
if not settings.ai_enabled:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="AI is not configured. Set GOOGLE_AI_API_KEY or ANTHROPIC_API_KEY.",
|
|
)
|
|
|
|
|
|
async def _check_quota(user: User, db: AsyncSession) -> None:
|
|
"""Check AI quota and raise 429 if exceeded."""
|
|
allowed, quota_status = await check_ai_quota(
|
|
user_id=user.id,
|
|
account_id=user.account_id,
|
|
db=db,
|
|
billing_anchor=user.ai_billing_cycle_anchor_at,
|
|
is_super_admin=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 limit exceeded ({quota_status['deny_reason']})",
|
|
"reset_at": quota_status.get(reset_key),
|
|
"quota": quota_status,
|
|
},
|
|
)
|
|
|
|
|
|
async def _record_usage(
|
|
user: User,
|
|
db: AsyncSession,
|
|
generation_type: str,
|
|
input_tokens: int,
|
|
output_tokens: int,
|
|
succeeded: bool,
|
|
session_id: Optional[UUID] = None,
|
|
error_code: Optional[str] = None,
|
|
) -> None:
|
|
"""Record AI usage after an LLM call."""
|
|
plan = await get_user_plan(user.account_id, db)
|
|
estimated_cost = (
|
|
input_tokens * 3.0 / 1_000_000
|
|
+ output_tokens * 15.0 / 1_000_000
|
|
)
|
|
await record_ai_usage(
|
|
user_id=user.id,
|
|
account_id=user.account_id,
|
|
conversation_id=None,
|
|
generation_type=generation_type,
|
|
tier=plan,
|
|
input_tokens=input_tokens,
|
|
output_tokens=output_tokens,
|
|
estimated_cost=estimated_cost,
|
|
succeeded=succeeded,
|
|
counts_toward_quota=True,
|
|
error_code=error_code,
|
|
extra_data={"ai_session_id": str(session_id)} if session_id else None,
|
|
db=db,
|
|
)
|
|
|
|
|
|
# ── Create session ──
|
|
|
|
@router.post("", response_model=AISessionCreateResponse, status_code=201)
|
|
@limiter.limit("5/minute")
|
|
async def create_session(
|
|
request: Request,
|
|
data: AISessionCreateRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
):
|
|
"""Start a new FlowPilot troubleshooting session."""
|
|
_require_ai_enabled()
|
|
await _check_quota(current_user, db)
|
|
|
|
try:
|
|
result = await flowpilot_engine.start_session(
|
|
request=data,
|
|
user_id=current_user.id,
|
|
account_id=current_user.account_id,
|
|
team_id=current_user.team_id,
|
|
db=db,
|
|
)
|
|
except Exception as e:
|
|
logger.exception("FlowPilot session start failed: %s", e)
|
|
await _record_usage(
|
|
current_user, db,
|
|
generation_type="flowpilot_start",
|
|
input_tokens=0, output_tokens=0,
|
|
succeeded=False, error_code=type(e).__name__,
|
|
)
|
|
await db.commit()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"AI provider error ({type(e).__name__}). Please try again.",
|
|
)
|
|
|
|
await _record_usage(
|
|
current_user, db,
|
|
generation_type="flowpilot_start",
|
|
input_tokens=result.first_step.confidence_score and 0, # Tracked on session
|
|
output_tokens=0,
|
|
succeeded=True,
|
|
session_id=result.session_id,
|
|
)
|
|
await db.commit()
|
|
|
|
return result
|
|
|
|
|
|
# ── Respond to step ──
|
|
|
|
@router.post("/{session_id}/respond", response_model=StepResponseResponse)
|
|
@limiter.limit("15/minute")
|
|
async def respond_to_step(
|
|
request: Request,
|
|
session_id: UUID,
|
|
data: StepResponseRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
):
|
|
"""Submit an engineer's response to a FlowPilot step and get the next step."""
|
|
_require_ai_enabled()
|
|
await _check_quota(current_user, db)
|
|
|
|
try:
|
|
result = await flowpilot_engine.process_response(
|
|
session_id=session_id,
|
|
request=data,
|
|
user_id=current_user.id,
|
|
db=db,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
except PermissionError as e:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
|
except Exception as e:
|
|
logger.exception("FlowPilot response failed: %s", e)
|
|
await _record_usage(
|
|
current_user, db,
|
|
generation_type="flowpilot_respond",
|
|
input_tokens=0, output_tokens=0,
|
|
succeeded=False,
|
|
session_id=session_id,
|
|
error_code=type(e).__name__,
|
|
)
|
|
await db.commit()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"AI provider error ({type(e).__name__}). Please try again.",
|
|
)
|
|
|
|
await _record_usage(
|
|
current_user, db,
|
|
generation_type="flowpilot_respond",
|
|
input_tokens=0, output_tokens=0,
|
|
succeeded=True,
|
|
session_id=session_id,
|
|
)
|
|
await db.commit()
|
|
|
|
return result
|
|
|
|
|
|
# ── Resolve ──
|
|
|
|
@router.post("/{session_id}/resolve", response_model=SessionCloseResponse)
|
|
@limiter.limit("15/minute")
|
|
async def resolve_session(
|
|
request: Request,
|
|
session_id: UUID,
|
|
data: ResolveSessionRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
):
|
|
"""Resolve a FlowPilot session and generate documentation."""
|
|
try:
|
|
result = await flowpilot_engine.resolve_session(
|
|
session_id=session_id,
|
|
request=data,
|
|
user_id=current_user.id,
|
|
db=db,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
except PermissionError as e:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
|
|
|
await db.commit()
|
|
return result
|
|
|
|
|
|
# ── Escalate ──
|
|
|
|
@router.post("/{session_id}/escalate", response_model=SessionCloseResponse)
|
|
@limiter.limit("15/minute")
|
|
async def escalate_session(
|
|
request: Request,
|
|
session_id: UUID,
|
|
data: EscalateSessionRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
):
|
|
"""Escalate a FlowPilot session to another engineer."""
|
|
try:
|
|
result = await flowpilot_engine.escalate_session(
|
|
session_id=session_id,
|
|
request=data,
|
|
user_id=current_user.id,
|
|
db=db,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
except PermissionError as e:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
|
|
|
await db.commit()
|
|
return result
|
|
|
|
|
|
# ── List sessions ──
|
|
|
|
@router.get("", response_model=list[AISessionSummary])
|
|
@limiter.limit("30/minute")
|
|
async def list_sessions(
|
|
request: Request,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
session_status: Optional[str] = Query(None, alias="status"),
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
):
|
|
"""List the current user's AI sessions."""
|
|
query = (
|
|
select(AISession)
|
|
.where(AISession.user_id == current_user.id)
|
|
.order_by(AISession.created_at.desc())
|
|
.offset(skip)
|
|
.limit(limit)
|
|
)
|
|
|
|
if session_status:
|
|
query = query.where(AISession.status == session_status)
|
|
|
|
result = await db.execute(query)
|
|
sessions = result.scalars().all()
|
|
|
|
return [AISessionSummary.model_validate(s) for s in sessions]
|
|
|
|
|
|
# ── Get session detail ──
|
|
|
|
@router.get("/{session_id}", response_model=AISessionDetail)
|
|
@limiter.limit("30/minute")
|
|
async def get_session(
|
|
request: Request,
|
|
session_id: UUID,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
):
|
|
"""Get full session detail with all steps."""
|
|
result = await db.execute(
|
|
select(AISession)
|
|
.options(selectinload(AISession.steps))
|
|
.where(AISession.id == session_id)
|
|
)
|
|
session = result.scalar_one_or_none()
|
|
|
|
if not session:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
|
|
|
# Allow access if user is owner or escalation target
|
|
if session.user_id != current_user.id and session.escalated_to_id != current_user.id:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
|
|
|
# Build step responses
|
|
step_responses = []
|
|
for step in session.steps:
|
|
options = []
|
|
if step.options_presented:
|
|
options = [
|
|
StepOptionSchema(
|
|
label=opt.get("label", ""),
|
|
value=opt.get("value", ""),
|
|
followup_hint=opt.get("followup_hint"),
|
|
)
|
|
for opt in step.options_presented
|
|
]
|
|
content = step.content or {}
|
|
step_responses.append(AISessionStepResponse(
|
|
step_id=step.id,
|
|
step_order=step.step_order,
|
|
step_type=step.step_type,
|
|
content=content,
|
|
context_message=step.context_message,
|
|
options=options,
|
|
allow_free_text=content.get("allow_free_text", True),
|
|
allow_skip=content.get("allow_skip", True),
|
|
confidence_tier=session.confidence_tier,
|
|
confidence_score=step.confidence_at_step,
|
|
))
|
|
|
|
detail = AISessionDetail.model_validate(session)
|
|
detail.steps = step_responses
|
|
return detail
|
|
|
|
|
|
# ── Documentation ──
|
|
|
|
@router.get("/{session_id}/documentation", response_model=SessionDocumentation)
|
|
@limiter.limit("30/minute")
|
|
async def get_documentation(
|
|
request: Request,
|
|
session_id: UUID,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
):
|
|
"""Get auto-generated documentation for a session."""
|
|
try:
|
|
return await flowpilot_engine.get_session_documentation(
|
|
session_id=session_id,
|
|
user_id=current_user.id,
|
|
db=db,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
|
except PermissionError as e:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
|
|
|
|
|
# ── Rate ──
|
|
|
|
@router.post("/{session_id}/rate", status_code=204)
|
|
@limiter.limit("15/minute")
|
|
async def rate_session(
|
|
request: Request,
|
|
session_id: UUID,
|
|
data: RateSessionRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
):
|
|
"""Submit a post-session rating."""
|
|
try:
|
|
await flowpilot_engine.rate_session(
|
|
session_id=session_id,
|
|
rating=data.rating,
|
|
feedback=data.feedback,
|
|
user_id=current_user.id,
|
|
db=db,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
|
except PermissionError as e:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
|
|
|
await db.commit()
|