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:
@@ -170,8 +170,32 @@ async def start_session(
|
||||
) -> AISessionCreateResponse:
|
||||
"""Start a new FlowPilot session: classify intake, match flows, get first step."""
|
||||
|
||||
# 0. Process PSA ticket intake if applicable
|
||||
ticket_context_block = None
|
||||
ticket_data = None
|
||||
psa_context_status = None
|
||||
|
||||
if request.intake_type == "psa_ticket" and request.psa_connection_id and request.psa_ticket_id:
|
||||
ticket_context_block, ticket_data, psa_context_status = await _process_ticket_intake(
|
||||
psa_connection_id=request.psa_connection_id,
|
||||
psa_ticket_id=request.psa_ticket_id,
|
||||
db=db,
|
||||
)
|
||||
# Enrich intake content with ticket context for classification
|
||||
if ticket_data:
|
||||
enriched_content = dict(request.intake_content)
|
||||
enriched_content["ticket_data"] = {
|
||||
"summary": ticket_data.get("ticket", {}).get("summary", ""),
|
||||
"company": ticket_data.get("company", {}).get("name", ""),
|
||||
"priority": ticket_data.get("ticket", {}).get("priority", ""),
|
||||
}
|
||||
request = request.model_copy(update={"intake_content": enriched_content})
|
||||
|
||||
# 1. Classify intake via fast LLM call
|
||||
intake_text = _extract_intake_text(request.intake_content)
|
||||
# Include ticket context in classification text if available
|
||||
if ticket_context_block:
|
||||
intake_text = f"{ticket_context_block}\n\n{intake_text}"
|
||||
classification = await _classify_intake(intake_text)
|
||||
|
||||
# 2. Try to match existing flows
|
||||
@@ -199,9 +223,14 @@ async def start_session(
|
||||
f"Use it as a guide but adapt to the specific situation."
|
||||
)
|
||||
|
||||
# Include ticket context in system prompt if available
|
||||
ticket_prompt_section = ""
|
||||
if ticket_context_block:
|
||||
ticket_prompt_section = f"\n## PSA TICKET CONTEXT\n{ticket_context_block}\n"
|
||||
|
||||
system_prompt = FLOWPILOT_SYSTEM_PROMPT.format(
|
||||
structured_output_schema=STRUCTURED_OUTPUT_SCHEMA,
|
||||
team_context="", # Phase 2: team-specific context
|
||||
team_context=ticket_prompt_section,
|
||||
matched_flow_context=matched_flow_context,
|
||||
)
|
||||
|
||||
@@ -261,6 +290,7 @@ async def start_session(
|
||||
match_score=match_score,
|
||||
psa_ticket_id=request.psa_ticket_id,
|
||||
psa_connection_id=request.psa_connection_id,
|
||||
ticket_data=ticket_data,
|
||||
total_input_tokens=input_tokens,
|
||||
total_output_tokens=output_tokens,
|
||||
step_count=1,
|
||||
@@ -294,6 +324,7 @@ async def start_session(
|
||||
matched_flow_name=matched_flow_name,
|
||||
match_score=match_score,
|
||||
first_step=_build_step_response(step, session),
|
||||
psa_context_status=psa_context_status,
|
||||
)
|
||||
|
||||
|
||||
@@ -419,10 +450,14 @@ async def resolve_session(
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Push documentation to PSA if ticket is linked
|
||||
psa_result = await _push_to_psa(session, user_id, db)
|
||||
|
||||
return SessionCloseResponse(
|
||||
session_id=session.id,
|
||||
status=session.status,
|
||||
documentation=documentation,
|
||||
**psa_result,
|
||||
)
|
||||
|
||||
|
||||
@@ -432,31 +467,276 @@ async def escalate_session(
|
||||
user_id: UUID,
|
||||
db: AsyncSession,
|
||||
) -> SessionCloseResponse:
|
||||
"""Escalate a session to another engineer."""
|
||||
"""Escalate a session — sets status to requesting_escalation for pickup."""
|
||||
session = await _load_session(session_id, user_id, db)
|
||||
|
||||
if session.status not in ("active", "paused"):
|
||||
raise ValueError(f"Cannot escalate session in status: {session.status}")
|
||||
|
||||
session.status = "escalated"
|
||||
session.resolved_at = datetime.now(timezone.utc)
|
||||
# Block self-escalation
|
||||
if request.escalated_to_id and request.escalated_to_id == user_id:
|
||||
raise ValueError("Cannot escalate a session to yourself. Use pause instead.")
|
||||
|
||||
session.status = "requesting_escalation"
|
||||
# Don't set resolved_at — session isn't done yet
|
||||
session.escalation_reason = request.escalation_reason
|
||||
session.escalated_to_id = request.escalated_to_id
|
||||
|
||||
# Build escalation package
|
||||
session.escalation_package = _build_escalation_package(session)
|
||||
# Build enhanced escalation package
|
||||
session.escalation_package = await _build_escalation_package_enhanced(session, user_id)
|
||||
|
||||
documentation = _generate_documentation(session)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Push documentation to PSA if ticket is linked
|
||||
psa_result = await _push_to_psa(session, user_id, db)
|
||||
|
||||
return SessionCloseResponse(
|
||||
session_id=session.id,
|
||||
status=session.status,
|
||||
documentation=documentation,
|
||||
**psa_result,
|
||||
)
|
||||
|
||||
|
||||
async def pickup_session(
|
||||
session_id: UUID,
|
||||
resume_mode: str,
|
||||
additional_context: Optional[str],
|
||||
user_id: UUID,
|
||||
team_id: Optional[UUID],
|
||||
db: AsyncSession,
|
||||
) -> StepResponseResponse:
|
||||
"""Pick up an escalated session as a new engineer.
|
||||
|
||||
Generates a briefing step summarizing prior work, then either continues
|
||||
the conversation or starts fresh with the new engineer's context.
|
||||
"""
|
||||
session = await _load_session(
|
||||
session_id, user_id, db,
|
||||
allow_team_access=True, team_id=team_id,
|
||||
)
|
||||
|
||||
if session.status != "requesting_escalation":
|
||||
raise ValueError(f"Session is {session.status}, not requesting_escalation")
|
||||
|
||||
# Can't pick up your own session
|
||||
if session.user_id == user_id:
|
||||
raise ValueError("Cannot pick up your own escalated session")
|
||||
|
||||
# Record the pickup in the escalation package
|
||||
pkg = session.escalation_package or {}
|
||||
pkg["picked_up_by"] = str(user_id)
|
||||
pkg["picked_up_at"] = datetime.now(timezone.utc).isoformat()
|
||||
session.escalation_package = pkg
|
||||
|
||||
# Reactivate the session
|
||||
session.status = "active"
|
||||
|
||||
# Build a briefing message for the new engineer
|
||||
original_user_name = "the previous engineer"
|
||||
if session.user and hasattr(session.user, 'display_name') and session.user.display_name:
|
||||
original_user_name = session.user.display_name
|
||||
|
||||
briefing_parts = [
|
||||
f"## Escalation Briefing",
|
||||
f"**Escalated by:** {original_user_name}",
|
||||
f"**Reason:** {session.escalation_reason or 'Not specified'}",
|
||||
"",
|
||||
f"**Problem:** {session.problem_summary or 'Unknown'}",
|
||||
]
|
||||
|
||||
steps_tried = pkg.get("steps_tried", [])
|
||||
if steps_tried:
|
||||
briefing_parts.append("")
|
||||
briefing_parts.append("**Steps already taken:**")
|
||||
for i, step in enumerate(steps_tried, 1):
|
||||
desc = step.get("description", "")
|
||||
resp = step.get("response", "")
|
||||
briefing_parts.append(f"{i}. {desc}")
|
||||
if resp:
|
||||
briefing_parts.append(f" → {resp}")
|
||||
|
||||
if hypotheses := pkg.get("remaining_hypotheses"):
|
||||
briefing_parts.append("")
|
||||
briefing_parts.append("**Remaining hypotheses:**")
|
||||
if isinstance(hypotheses, list):
|
||||
for h in hypotheses:
|
||||
briefing_parts.append(f"- {h}")
|
||||
else:
|
||||
briefing_parts.append(str(hypotheses))
|
||||
|
||||
if suggestions := pkg.get("suggested_next_steps"):
|
||||
briefing_parts.append("")
|
||||
briefing_parts.append("**Suggested next steps:**")
|
||||
if isinstance(suggestions, list):
|
||||
for s in suggestions:
|
||||
briefing_parts.append(f"- {s}")
|
||||
else:
|
||||
briefing_parts.append(str(suggestions))
|
||||
|
||||
briefing_text = "\n".join(briefing_parts)
|
||||
|
||||
# Create a briefing step (special intake_analysis type)
|
||||
briefing_step = AISessionStep(
|
||||
id=uuid.uuid4(),
|
||||
session_id=session.id,
|
||||
step_order=session.step_count,
|
||||
step_type="action",
|
||||
content={
|
||||
"text": briefing_text,
|
||||
"type": "briefing",
|
||||
"allow_free_text": False,
|
||||
"allow_skip": False,
|
||||
},
|
||||
context_message="Escalation briefing — here's what was tried before you.",
|
||||
confidence_at_step=session.confidence_score,
|
||||
ai_reasoning="Escalation handoff briefing for receiving engineer",
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
)
|
||||
db.add(briefing_step)
|
||||
session.step_count += 1
|
||||
|
||||
# Now generate the next step based on resume_mode
|
||||
if resume_mode == "fresh" and additional_context:
|
||||
# Engineer B provides their own input
|
||||
user_message = f"[Picking up escalated session] {additional_context}"
|
||||
else:
|
||||
# Continue where A left off
|
||||
user_message = (
|
||||
"[Picking up escalated session] I've reviewed the briefing above. "
|
||||
"Please continue the diagnosis based on everything tried so far."
|
||||
)
|
||||
|
||||
# Append to conversation
|
||||
session.conversation_messages = session.conversation_messages + [
|
||||
{"role": "user", "content": user_message}
|
||||
]
|
||||
|
||||
# Call LLM for next step
|
||||
provider = get_ai_provider(settings.get_model_for_action("open_chat"))
|
||||
raw_response, input_tokens, output_tokens = await provider.generate_json(
|
||||
system_prompt=session.system_prompt_snapshot or "",
|
||||
messages=session.conversation_messages,
|
||||
max_tokens=2048,
|
||||
)
|
||||
|
||||
try:
|
||||
parsed = _parse_structured_output(raw_response)
|
||||
except ValueError:
|
||||
retry_messages = session.conversation_messages + [
|
||||
{"role": "assistant", "content": raw_response},
|
||||
{"role": "user", "content": "Please respond with ONLY valid JSON matching the required schema."},
|
||||
]
|
||||
raw_response, retry_in, retry_out = await provider.generate_json(
|
||||
system_prompt=session.system_prompt_snapshot or "",
|
||||
messages=retry_messages,
|
||||
max_tokens=2048,
|
||||
)
|
||||
input_tokens += retry_in
|
||||
output_tokens += retry_out
|
||||
parsed = _parse_structured_output(raw_response)
|
||||
|
||||
session.conversation_messages = session.conversation_messages + [
|
||||
{"role": "assistant", "content": raw_response}
|
||||
]
|
||||
|
||||
confidence = parsed.get("confidence", session.confidence_score)
|
||||
session.confidence_score = confidence
|
||||
session.confidence_tier = _confidence_to_tier(confidence)
|
||||
session.total_input_tokens += input_tokens
|
||||
session.total_output_tokens += output_tokens
|
||||
session.step_count += 1
|
||||
|
||||
next_step = _create_step_from_parsed(
|
||||
session_id=session.id,
|
||||
step_order=session.step_count - 1,
|
||||
parsed=parsed,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
db.add(next_step)
|
||||
|
||||
await db.flush()
|
||||
|
||||
return StepResponseResponse(
|
||||
session_id=session.id,
|
||||
status=session.status,
|
||||
confidence_tier=session.confidence_tier,
|
||||
confidence_score=session.confidence_score,
|
||||
next_step=_build_step_response(next_step, session),
|
||||
resolution_suggested=parsed["type"] == "resolution_suggestion",
|
||||
resolution_summary=parsed.get("resolution_summary") if parsed["type"] == "resolution_suggestion" else None,
|
||||
)
|
||||
|
||||
|
||||
async def link_ticket(
|
||||
session_id: UUID,
|
||||
psa_ticket_id: str,
|
||||
psa_connection_id: UUID,
|
||||
user_id: UUID,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""Link a PSA ticket to an in-progress session and inject context."""
|
||||
session = await _load_session(session_id, user_id, db)
|
||||
|
||||
if session.status not in ("active", "paused"):
|
||||
raise ValueError(f"Cannot link ticket to session in status: {session.status}")
|
||||
|
||||
# Store the ticket link
|
||||
session.psa_ticket_id = psa_ticket_id
|
||||
session.psa_connection_id = psa_connection_id
|
||||
|
||||
# Try to fetch ticket context
|
||||
ticket_context_block, ticket_data, _ = await _process_ticket_intake(
|
||||
psa_connection_id=psa_connection_id,
|
||||
psa_ticket_id=psa_ticket_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if ticket_data:
|
||||
session.ticket_data = ticket_data
|
||||
|
||||
# Inject ticket context into the system prompt for subsequent steps
|
||||
if ticket_context_block and session.system_prompt_snapshot:
|
||||
ticket_section = f"\n\n## PSA TICKET CONTEXT (linked mid-session)\n{ticket_context_block}\n"
|
||||
session.system_prompt_snapshot = session.system_prompt_snapshot + ticket_section
|
||||
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def pause_session(
|
||||
session_id: UUID,
|
||||
user_id: UUID,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""Pause an active session for the same engineer to resume later."""
|
||||
session = await _load_session(session_id, user_id, db)
|
||||
|
||||
if session.status != "active":
|
||||
raise ValueError(f"Cannot pause session in status: {session.status}")
|
||||
|
||||
session.status = "paused"
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def resume_session(
|
||||
session_id: UUID,
|
||||
user_id: UUID,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""Resume a paused session for the same engineer."""
|
||||
session = await _load_session(session_id, user_id, db)
|
||||
|
||||
if session.status != "paused":
|
||||
raise ValueError(f"Cannot resume session in status: {session.status}")
|
||||
|
||||
session.status = "active"
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def rate_session(
|
||||
session_id: UUID,
|
||||
rating: int,
|
||||
@@ -487,11 +767,23 @@ async def _load_session(
|
||||
session_id: UUID,
|
||||
user_id: UUID,
|
||||
db: AsyncSession,
|
||||
allow_team_access: bool = False,
|
||||
team_id: Optional[UUID] = None,
|
||||
) -> AISession:
|
||||
"""Load session with steps, verifying ownership."""
|
||||
"""Load session with steps and user relationships, verifying ownership.
|
||||
|
||||
Args:
|
||||
allow_team_access: If True, same-team users can access sessions in
|
||||
'requesting_escalation' status (for escalation pickup).
|
||||
team_id: Required when allow_team_access is True.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AISession)
|
||||
.options(selectinload(AISession.steps))
|
||||
.options(
|
||||
selectinload(AISession.steps),
|
||||
selectinload(AISession.user),
|
||||
selectinload(AISession.escalated_to),
|
||||
)
|
||||
.where(AISession.id == session_id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
@@ -499,11 +791,21 @@ async def _load_session(
|
||||
if not session:
|
||||
raise ValueError("Session not found")
|
||||
|
||||
# Allow access if user is the session owner or the escalation target
|
||||
if session.user_id != user_id and session.escalated_to_id != user_id:
|
||||
raise PermissionError("Not authorized to access this session")
|
||||
# Owner or escalation target always has access
|
||||
if session.user_id == user_id or session.escalated_to_id == user_id:
|
||||
return session
|
||||
|
||||
return session
|
||||
# Engineer who picked up an escalated session has access
|
||||
pkg = session.escalation_package or {}
|
||||
if pkg.get("picked_up_by") == str(user_id):
|
||||
return session
|
||||
|
||||
# Team-based access for escalation pickup
|
||||
if allow_team_access and team_id and session.team_id == team_id:
|
||||
if session.status == "requesting_escalation":
|
||||
return session
|
||||
|
||||
raise PermissionError("Not authorized to access this session")
|
||||
|
||||
|
||||
async def _classify_intake(intake_text: str) -> dict[str, Any]:
|
||||
@@ -708,8 +1010,67 @@ def _generate_documentation(session: AISession) -> SessionDocumentation:
|
||||
)
|
||||
|
||||
|
||||
def _build_escalation_package(session: AISession) -> dict[str, Any]:
|
||||
"""Build context package for the receiving engineer."""
|
||||
async def _push_to_psa(
|
||||
session: AISession,
|
||||
user_id: UUID,
|
||||
db: AsyncSession,
|
||||
) -> dict[str, Any]:
|
||||
"""Push documentation to PSA if session has a linked ticket.
|
||||
|
||||
Returns dict with psa_push_status, psa_push_error, member_mapping_warning.
|
||||
"""
|
||||
if not session.psa_ticket_id or not session.psa_connection_id:
|
||||
return {"psa_push_status": "no_psa", "psa_push_error": None, "member_mapping_warning": None}
|
||||
|
||||
try:
|
||||
from app.services.psa_documentation_service import push_documentation
|
||||
return await push_documentation(session, user_id, db)
|
||||
except Exception as e:
|
||||
logger.warning("PSA documentation push failed for session %s: %s", session.id, e)
|
||||
return {
|
||||
"psa_push_status": "failed",
|
||||
"psa_push_error": str(e)[:200],
|
||||
"member_mapping_warning": None,
|
||||
}
|
||||
|
||||
|
||||
async def _process_ticket_intake(
|
||||
psa_connection_id: UUID,
|
||||
psa_ticket_id: str,
|
||||
db: AsyncSession,
|
||||
) -> tuple[Optional[str], Optional[dict[str, Any]], str]:
|
||||
"""Fetch ticket context from PSA and format for AI prompt.
|
||||
|
||||
Returns:
|
||||
(ticket_context_block, ticket_data_dict, psa_context_status)
|
||||
- ticket_context_block: formatted text for system prompt, or None on failure
|
||||
- ticket_data_dict: serialized TicketContext for storage, or None on failure
|
||||
- psa_context_status: "loaded" or "unavailable"
|
||||
"""
|
||||
try:
|
||||
from app.services.psa.registry import get_provider_for_connection
|
||||
from app.services.psa.ticket_context import format_ticket_context_for_prompt
|
||||
|
||||
provider = await get_provider_for_connection(psa_connection_id, db)
|
||||
ticket_context = await provider.get_ticket_context(
|
||||
int(psa_ticket_id), str(psa_connection_id)
|
||||
)
|
||||
ticket_prompt_block = format_ticket_context_for_prompt(ticket_context)
|
||||
ticket_data = ticket_context.model_dump(mode="json")
|
||||
return ticket_prompt_block, ticket_data, "loaded"
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to fetch ticket context for ticket %s (connection %s): %s",
|
||||
psa_ticket_id, psa_connection_id, e,
|
||||
)
|
||||
return None, None, "unavailable"
|
||||
|
||||
|
||||
async def _build_escalation_package_enhanced(
|
||||
session: AISession,
|
||||
user_id: UUID,
|
||||
) -> dict[str, Any]:
|
||||
"""Build enhanced context package with LLM-generated hypotheses."""
|
||||
steps_tried = []
|
||||
for step in session.steps:
|
||||
content = step.content or {}
|
||||
@@ -727,7 +1088,8 @@ def _build_escalation_package(session: AISession) -> dict[str, Any]:
|
||||
entry["action_result"] = step.action_result
|
||||
steps_tried.append(entry)
|
||||
|
||||
return {
|
||||
package = {
|
||||
"original_user_id": str(user_id),
|
||||
"problem_summary": session.problem_summary,
|
||||
"problem_domain": session.problem_domain,
|
||||
"intake_content": session.intake_content,
|
||||
@@ -735,3 +1097,36 @@ def _build_escalation_package(session: AISession) -> dict[str, Any]:
|
||||
"steps_tried": steps_tried,
|
||||
"escalation_reason": session.escalation_reason,
|
||||
}
|
||||
|
||||
# LLM call for remaining hypotheses and suggested next steps (fast model)
|
||||
try:
|
||||
conversation_summary = "\n".join(
|
||||
f"- {s.get('description', '')} → {s.get('response', 'no response')}"
|
||||
for s in steps_tried
|
||||
)
|
||||
prompt = (
|
||||
"Based on this diagnostic conversation for an IT troubleshooting session:\n\n"
|
||||
f"Problem: {session.problem_summary}\n"
|
||||
f"Domain: {session.problem_domain}\n\n"
|
||||
f"Steps taken:\n{conversation_summary}\n\n"
|
||||
f"Escalation reason: {session.escalation_reason}\n\n"
|
||||
"Respond with ONLY a JSON object:\n"
|
||||
'{"remaining_hypotheses": ["hypothesis1", "hypothesis2"], '
|
||||
'"suggested_next_steps": ["step1", "step2"], '
|
||||
'"steps_ruled_out": ["ruled_out1"]}'
|
||||
)
|
||||
provider = get_ai_provider(settings.get_model_for_action("quick_action"))
|
||||
raw, _, _ = await provider.generate_json(
|
||||
system_prompt="You are an expert IT diagnostic assistant. Analyze the escalation context and provide concise insights.",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=1024,
|
||||
)
|
||||
insights = json.loads(raw.strip().strip("`").lstrip("json\n"))
|
||||
package["remaining_hypotheses"] = insights.get("remaining_hypotheses", [])
|
||||
package["suggested_next_steps"] = insights.get("suggested_next_steps", [])
|
||||
package["steps_ruled_out"] = insights.get("steps_ruled_out", [])
|
||||
except Exception as e:
|
||||
logger.warning("Failed to generate escalation insights: %s", e)
|
||||
# Fall back gracefully — don't block the escalation
|
||||
|
||||
return package
|
||||
|
||||
Reference in New Issue
Block a user