feat(ai-session): add Phase 2 PSA integration, escalation handoff, and session management
Phase 2 of the FlowPilot-First Pivot connecting AI sessions to ConnectWise PSA: Slice 1 — PSA Ticket Intake: - FlowPilotEngine accepts psa_ticket intake with graceful CW API fallback - Ticket picker on intake screen (refactored TicketPickerModal for dual-mode) - Ticket context card in session sidebar Slice 2 — Auto Documentation Push: - PSA documentation service with resolution/escalation note formatting - Time entry creation via new ConnectWise provider method - Automatic retry scheduler (APScheduler, 5min interval, 3 retries) - PSA push status indicators in frontend with manual retry button - Member mapping warning when CW member not mapped Slice 3 — Session Pause/Resume & Escalation Handoff: - Pause/resume endpoints for same-engineer session bookmarking - Escalation flow: requesting_escalation status, self-escalation blocked - Enhanced escalation package with LLM-generated hypotheses/suggestions - Pickup endpoint with continue/fresh resume modes and briefing step - Escalation queue (sidebar nav + dedicated page) - SessionBriefing component with continue/fresh choice UI - EscalateModal with PSA-aware button text Slice 4 — Mid-Session Ticket Linking: - Link ticket retroactively with context injection into system prompt - Link Ticket button in session sidebar Slice 5 — FlowPilot PSA Settings: - Settings tab on IntegrationsPage with 7 configurable options - Stored as flowpilot_settings JSONB on PsaConnection Database: 2 migrations (flowpilot_settings, psa_post_log changes, status constraint) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,12 +35,15 @@ from app.schemas.ai_session import (
|
||||
SessionCloseResponse,
|
||||
SessionDocumentation,
|
||||
RateSessionRequest,
|
||||
PickupSessionRequest,
|
||||
LinkTicketRequest,
|
||||
AISessionSummary,
|
||||
AISessionDetail,
|
||||
AISessionStepResponse,
|
||||
StepOptionSchema,
|
||||
)
|
||||
from app.services import flowpilot_engine
|
||||
from app.services.psa_documentation_service import retry_failed_push
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -272,6 +275,184 @@ async def escalate_session(
|
||||
return result
|
||||
|
||||
|
||||
# ── Pause ──
|
||||
|
||||
@router.post("/{session_id}/pause", status_code=204)
|
||||
@limiter.limit("15/minute")
|
||||
async def pause_session(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Pause an active FlowPilot session for later resume."""
|
||||
try:
|
||||
await flowpilot_engine.pause_session(
|
||||
session_id=session_id,
|
||||
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()
|
||||
|
||||
|
||||
# ── Resume ──
|
||||
|
||||
@router.post("/{session_id}/resume", status_code=204)
|
||||
@limiter.limit("15/minute")
|
||||
async def resume_session(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Resume a paused FlowPilot session."""
|
||||
try:
|
||||
await flowpilot_engine.resume_session(
|
||||
session_id=session_id,
|
||||
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()
|
||||
|
||||
|
||||
# ── Escalation Queue ──
|
||||
|
||||
@router.get("/escalation-queue", response_model=list[AISessionSummary])
|
||||
@limiter.limit("30/minute")
|
||||
async def get_escalation_queue(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""List sessions requesting escalation for the current user's team."""
|
||||
if not current_user.team_id:
|
||||
return []
|
||||
|
||||
result = await db.execute(
|
||||
select(AISession)
|
||||
.where(
|
||||
AISession.team_id == current_user.team_id,
|
||||
AISession.status == "requesting_escalation",
|
||||
AISession.user_id != current_user.id, # Don't show own escalated sessions
|
||||
)
|
||||
.order_by(AISession.created_at.desc())
|
||||
)
|
||||
sessions = result.scalars().all()
|
||||
return [AISessionSummary.model_validate(s) for s in sessions]
|
||||
|
||||
|
||||
# ── Pickup Escalated Session ──
|
||||
|
||||
@router.post("/{session_id}/pickup", response_model=StepResponseResponse)
|
||||
@limiter.limit("5/minute")
|
||||
async def pickup_session(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
data: PickupSessionRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Pick up an escalated session as a new engineer."""
|
||||
_require_ai_enabled()
|
||||
await _check_quota(current_user, db)
|
||||
|
||||
try:
|
||||
result = await flowpilot_engine.pickup_session(
|
||||
session_id=session_id,
|
||||
resume_mode=data.resume_mode,
|
||||
additional_context=data.additional_context,
|
||||
user_id=current_user.id,
|
||||
team_id=current_user.team_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 pickup failed: %s", e)
|
||||
await _record_usage(
|
||||
current_user, db,
|
||||
generation_type="flowpilot_pickup",
|
||||
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_pickup",
|
||||
input_tokens=0, output_tokens=0,
|
||||
succeeded=True,
|
||||
session_id=session_id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── Link Ticket ──
|
||||
|
||||
@router.post("/{session_id}/link-ticket", response_model=AISessionDetail)
|
||||
@limiter.limit("10/minute")
|
||||
async def link_ticket_to_session(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
data: LinkTicketRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Link a PSA ticket to an in-progress session retroactively."""
|
||||
try:
|
||||
await flowpilot_engine.link_ticket(
|
||||
session_id=session_id,
|
||||
psa_ticket_id=data.psa_ticket_id,
|
||||
psa_connection_id=data.psa_connection_id,
|
||||
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 updated session detail
|
||||
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")
|
||||
|
||||
detail = AISessionDetail.model_validate(session)
|
||||
return detail
|
||||
|
||||
|
||||
# ── List sessions ──
|
||||
|
||||
@router.get("", response_model=list[AISessionSummary])
|
||||
@@ -323,8 +504,10 @@ async def get_session(
|
||||
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:
|
||||
# Allow access if user is owner, escalation target, or picked-up handler
|
||||
pkg = session.escalation_package or {}
|
||||
is_handler = pkg.get("picked_up_by") == str(current_user.id)
|
||||
if session.user_id != current_user.id and session.escalated_to_id != current_user.id and not is_handler:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||
|
||||
# Build step responses
|
||||
@@ -409,3 +592,48 @@ async def rate_session(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Retry PSA Push ──
|
||||
|
||||
@router.post("/{session_id}/retry-psa-push")
|
||||
@limiter.limit("5/minute")
|
||||
async def retry_psa_push_endpoint(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Manually retry a failed PSA documentation push."""
|
||||
from app.models.psa_post_log import PsaPostLog
|
||||
|
||||
# Find the latest failed push log for this session
|
||||
result = await db.execute(
|
||||
select(PsaPostLog)
|
||||
.where(
|
||||
PsaPostLog.ai_session_id == session_id,
|
||||
PsaPostLog.status.in_(["failed", "pending_retry"]),
|
||||
)
|
||||
.order_by(PsaPostLog.posted_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
log_entry = result.scalar_one_or_none()
|
||||
|
||||
if not log_entry:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No failed PSA push found for this session",
|
||||
)
|
||||
|
||||
# Reset to pending_retry and attempt immediately
|
||||
log_entry.status = "pending_retry"
|
||||
log_entry.retry_count = max(0, log_entry.retry_count - 1) # Give one more attempt
|
||||
|
||||
success = await retry_failed_push(log_entry, db)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"psa_push_status": "sent" if success else log_entry.status,
|
||||
"psa_push_error": log_entry.error_message if not success else None,
|
||||
}
|
||||
|
||||
@@ -279,6 +279,69 @@ async def test_connection(
|
||||
return result
|
||||
|
||||
|
||||
# ── FlowPilot PSA Settings ──────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/connections/{connection_id}/flowpilot-settings")
|
||||
async def get_flowpilot_settings(
|
||||
connection_id: UUID,
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get FlowPilot-specific settings for a PSA connection."""
|
||||
conn = await _get_connection_or_404(connection_id, current_user, db)
|
||||
# Return settings with defaults filled in
|
||||
defaults = {
|
||||
"auto_push": True,
|
||||
"auto_time_entry": True,
|
||||
"time_rounding": "15min",
|
||||
"note_visibility": "internal",
|
||||
"include_diagnostic_steps": True,
|
||||
"prompt_status_on_resolution": False,
|
||||
"prompt_status_on_escalation": False,
|
||||
}
|
||||
settings_data = {**defaults, **(conn.flowpilot_settings or {})}
|
||||
return settings_data
|
||||
|
||||
|
||||
@router.put("/connections/{connection_id}/flowpilot-settings")
|
||||
async def update_flowpilot_settings(
|
||||
connection_id: UUID,
|
||||
data: dict,
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Update FlowPilot-specific settings for a PSA connection."""
|
||||
conn = await _get_connection_or_404(connection_id, current_user, db)
|
||||
|
||||
# Validate allowed keys
|
||||
allowed_keys = {
|
||||
"auto_push", "auto_time_entry", "time_rounding",
|
||||
"note_visibility", "include_diagnostic_steps",
|
||||
"prompt_status_on_resolution", "prompt_status_on_escalation",
|
||||
}
|
||||
filtered = {k: v for k, v in data.items() if k in allowed_keys}
|
||||
|
||||
# Merge with existing
|
||||
current = conn.flowpilot_settings or {}
|
||||
current.update(filtered)
|
||||
conn.flowpilot_settings = current
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(conn)
|
||||
|
||||
defaults = {
|
||||
"auto_push": True,
|
||||
"auto_time_entry": True,
|
||||
"time_rounding": "15min",
|
||||
"note_visibility": "internal",
|
||||
"include_diagnostic_steps": True,
|
||||
"prompt_status_on_resolution": False,
|
||||
"prompt_status_on_escalation": False,
|
||||
}
|
||||
return {**defaults, **(conn.flowpilot_settings or {})}
|
||||
|
||||
|
||||
# ── ticket / status / company endpoints ──────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user