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:
2026-03-19 01:30:05 +00:00
parent 2063a799b0
commit bbe590bfec
37 changed files with 3698 additions and 121 deletions

View File

@@ -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,
}

View File

@@ -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 ──────────────────────────