Files
resolutionflow/backend/app/api/endpoints/ai_sessions.py
chihlasm 5494816b06 feat(ai-session): add FlowPilot AI-powered troubleshooting sessions
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>
2026-03-18 14:27:36 +00:00

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()