Files
chihlasm 8534dbfb5f feat: command palette, PSA ticket context, session-to-flow converter (#108)
* feat: add paletteIntent utility for command palette query classification

Detects query intent ('question' | 'keyword' | 'page' | 'empty') to drive
smart result ordering in the enhanced command palette.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add recentFlows localStorage utility for command palette empty state

Tracks recently visited flows (capped at 10) with deduplication by id,
surfaced in command palette when query is empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: rewrite CommandPalette with categorized results and smart ranking

- Adds FlowPilot AI result (always present when query is non-empty)
- Intent-aware ordering: question → FlowPilot prominent; page → pages first;
  keyword → FlowPilot at top with flows/sessions/tags below
- Pages section with admin-gated items (uses useAuthStore)
- Tags extracted from flow search results with ?tag= navigation
- Quick Actions for create/import/scripts
- Empty state shows recent flows + quick actions
- Grouped rendering with section labels per design system
- Keyboard nav flattened across groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add FlowPilot prefill handoff from command palette to AssistantChatPage

When navigated to /assistant with location.state.prefill, automatically
creates a new chat and sends the prefill message without user interaction.
Clears location state after handling to prevent re-trigger on back navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: track recently visited flows for command palette empty state

Calls addRecentFlow after tree data loads in both TreeNavigationPage and
ProceduralNavigationPage so the command palette can surface recent flows
when the query is empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: use useMemo instead of useCallback for groups builder in CommandPalette

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add PSA ticket context Pydantic schemas (Task 6)

Add TicketDetails, CompanyInfo, ContactInfo, ConfigItem, TicketNote,
RelatedTicket, and TicketContext models in schemas/psa_context.py for
structured ticket context enrichment used by AI prompt injection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add ticket context prompt formatter (Task 7)

format_ticket_context_for_prompt() in services/psa/ticket_context.py
serializes TicketContext into structured text for AI system prompts,
with 10-note limit, 200-char text previews, and human-readable timestamps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add get_ticket_context() to ConnectWise provider (Task 8)

Fetches ticket details, company, contact, configurations, notes, and
related open tickets in parallel via asyncio.gather with partial failure
tolerance. Results are cached with a 5-minute TTL per ticket/connection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add GET /integrations/psa/tickets/{id}/context endpoint (Task 9)

Returns rich TicketContext for a ticket ID. Handles PSA auth failures
(returns structured error), ticket-not-found (404), and general PSA
errors (502). Requires active PSA connection for the user's account.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: inject PSA ticket context into copilot system prompt (Task 10)

When a copilot conversation has an associated session with a linked PSA
ticket, fetch the ticket context and append it to the system prompt.
Failure is non-critical — errors are logged and the copilot proceeds
without context rather than failing the request.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add PSA context API client with TypeScript interfaces

Defines TicketDetails, CompanyInfo, ContactInfo, ConfigItemInfo,
TicketNote, RelatedTicket, and TicketContext interfaces matching backend
psa_context.py schemas. Exports psaContextApi with getTicketContext().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add useTicketContext hook for PSA ticket context fetching

Accepts psaTicketId and psaConnectionId, fetches context on mount
when both IDs are present, and exposes refresh() for manual re-fetch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add TicketContextPanel component with accordion sections

Glass-card panel showing ticket summary, status/priority/SLA, and
accordion sections for Client, Contact, Devices, Notes, and Related
tickets. Matches design system with font-label labels and ice-cyan accents.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: mount TicketContextPanel in session runners when ticket is linked

ProceduralNavigationPage renders panel in left sidebar below step checklist.
TreeNavigationPage renders panel above breadcrumb trail. Both use
useTicketContext hook and show panel only when psa_ticket_id is set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add fallback_steps to TypeScript types (Task 15)

Add optional fallback_steps field to ProceduralStep interface.
Add FallbackStepRecord interface and fallback_decisions field to Session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add backend validation for fallback steps (Task 16)

Validate fallback_steps in procedural flow validation: required fields,
no nested fallback_steps, no duplicate IDs. Add FallbackStepRecord schema
and fallback_decisions field to SessionResponse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: create FallbackSteps UI component (Task 17)

Collapsible component supporting edit and execute modes. Edit mode
provides title/description inputs with add/remove controls. Execute
mode shows "This worked" / "Didn't help" action buttons with emerald/
rose styling. Amber accent styling throughout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: integrate FallbackSteps into editor and session runner (Task 18)

Wire FallbackSteps edit mode into StepEditor for procedure_step type
with add/remove/update handlers using crypto.randomUUID(). Add execute
mode rendering in ProceduralNavigationPage with fallbackDecisions state
tracking per parent step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add session-to-flow request/response schemas (Task 19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add session-to-flow AI generation service (Task 20)

Converts completed troubleshooting sessions into reusable procedural flows
with fallback branches. Includes PSA ticket context integration and
AI-generated step validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add POST /ai/session-to-flow endpoint (Task 21)

Converts a completed session into a reusable procedural flow using AI.
Includes quota checking, usage recording, and proper error handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Create Flow from Session button to session detail page (Task 22)

Adds sessionToFlow API client, exports from api/index.ts, and integrates
a prominent "Create Flow from Session" button on SessionDetailPage for
completed sessions. Generates a procedural flow via AI then navigates
to the procedural editor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: cast tree_type to TreeType in session-to-flow creation

Fixes build error where string was not assignable to TreeType.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update Playwright test selectors to match actual UI

- Use Control+k instead of Meta+k (Linux/CI compatibility)
- Use 'AI Assistant' group label instead of 'FlowPilot AI'
- Match actual FlowPilot chat page elements (Start a Conversation, New Chat, textarea)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update Playwright test selectors to match actual UI

- Use specific command palette placeholder to avoid ambiguous matches
- Fix 'Quick Actions' scoping (two elements with same text)
- Fix 'Resolved' exact match on session detail page
- Fix tree editor to use getByText instead of getByDisplayValue
- Fix 'Add Step' strict mode by using .first()
- Fix fallback description placeholder text
- Update playwright.config.ts to use port 5433 and resolutionflow DB
- Update FlowPilot chat selectors to match actual page layout

11/17 new tests now passing. Remaining 6 need procedural session
navigation investigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve all Playwright test failures — 16/16 passing

- Fix procedural session tests: sessions auto-start, no Start button
- Fix strict mode violations: use getByRole('heading') for step titles
- Fix FlowPilot chat: use button role selector for New Chat
- Fix command palette page nav: scope Analytics click to palette modal
- Fix fallback runner: remove non-existent Start button click
- Update playwright.config to port 5433 for local Docker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:39:17 -04:00

539 lines
17 KiB
Python

"""AI Flow Builder wizard endpoints.
4-stage wizard:
POST /ai/start — Stage 1: create conversation with metadata
POST /ai/scaffold — Stage 2: AI suggests branches
POST /ai/branch-detail — Stage 3: AI generates detail for one branch
POST /ai/assemble — Stage 4: assemble branches into tree (no AI)
GET /ai/quota — quota status
Session conversion:
POST /ai/session-to-flow — Convert a completed session into a procedural flow
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
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_conversation_store import (
create_conversation,
get_conversation,
update_conversation,
)
from app.core.ai_quota_service import check_ai_quota, record_ai_usage, get_user_plan
from app.core.ai_tree_generator_service import (
scaffold_branches,
generate_branch_detail,
assemble_tree,
)
from app.models.user import User
from app.schemas.ai_builder import (
AIStartRequest,
AIStartResponse,
AIScaffoldRequest,
AIScaffoldResponse,
AIBranchDetailRequest,
AIBranchDetailResponse,
AIAssembleRequest,
AIAssembleResponse,
AIQuotaStatusResponse,
)
from app.schemas.session_to_flow import SessionToFlowRequest, SessionToFlowResponse
from app.services.session_to_flow_service import generate_flow_from_session
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ai", tags=["ai-builder"])
def _require_ai_enabled() -> None:
"""Raise 503 if AI is not configured."""
if not settings.ai_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AI flow builder is not configured. Set GOOGLE_AI_API_KEY or ANTHROPIC_API_KEY.",
)
@router.get("/quota", response_model=AIQuotaStatusResponse)
async def get_quota(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get current user's AI quota status."""
if not settings.ai_enabled:
return AIQuotaStatusResponse(
plan="free",
monthly_used=0,
monthly_limit=None,
monthly_reset_at="",
daily_used=0,
daily_limit=None,
daily_reset_at="",
allowed=False,
ai_enabled=False,
)
_, quota_status = await check_ai_quota(
user_id=current_user.id,
account_id=current_user.account_id,
db=db,
billing_anchor=current_user.ai_billing_cycle_anchor_at,
is_super_admin=current_user.is_super_admin,
)
return AIQuotaStatusResponse(
**quota_status,
ai_enabled=True,
)
@router.post("/start", response_model=AIStartResponse, status_code=201)
@limiter.limit("10/minute")
async def start_conversation(
request: Request,
data: AIStartRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Stage 1: Create a new AI wizard conversation with foundation metadata."""
_require_ai_enabled()
# Check daily quota (anti-abuse) — super admins bypass
allowed, quota_status = await check_ai_quota(
user_id=current_user.id,
account_id=current_user.account_id,
db=db,
billing_anchor=current_user.ai_billing_cycle_anchor_at,
is_super_admin=current_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 build limit exceeded ({quota_status['deny_reason']})",
"reset_at": quota_status.get(reset_key),
"quota": quota_status,
},
)
wizard_state = {
"flow_type": data.flow_type,
"name": data.name,
"description": data.description,
"environment_tags": data.environment_tags,
"category_id": str(data.category_id) if data.category_id else None,
}
conversation = await create_conversation(
user_id=current_user.id,
account_id=current_user.account_id,
wizard_state=wizard_state,
db=db,
)
await db.commit()
return AIStartResponse(
conversation_id=conversation.id,
status=conversation.status,
)
@router.post("/scaffold", response_model=AIScaffoldResponse)
@limiter.limit("10/minute")
async def scaffold(
request: Request,
data: AIScaffoldRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Stage 2: AI suggests top-level branches."""
_require_ai_enabled()
conversation = await get_conversation(
data.conversation_id, current_user.id, db
)
# Check per-flow call limit
if conversation.question_rounds >= settings.AI_MAX_CALLS_PER_FLOW:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Maximum AI calls per flow exceeded",
)
plan = await get_user_plan(current_user.account_id, db)
try:
branches, input_tokens, output_tokens, cost = await scaffold_branches(
conversation.wizard_state,
)
except ValueError as e:
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=conversation.id,
generation_type="scaffold",
tier=plan,
input_tokens=0,
output_tokens=0,
estimated_cost=0,
succeeded=False,
counts_toward_quota=False,
error_code="invalid_output",
extra_data={"error": str(e)},
db=db,
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"AI returned invalid output: {e}",
)
except Exception as e:
logger.exception("AI scaffold failed: %s: %s", type(e).__name__, e)
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=conversation.id,
generation_type="scaffold",
tier=plan,
input_tokens=0,
output_tokens=0,
estimated_cost=0,
succeeded=False,
counts_toward_quota=False,
error_code=type(e).__name__,
extra_data={"error": str(e)},
db=db,
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider error ({type(e).__name__}). Please try again.",
)
# Record successful usage
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=conversation.id,
generation_type="scaffold",
tier=plan,
input_tokens=input_tokens,
output_tokens=output_tokens,
estimated_cost=cost,
succeeded=True,
counts_toward_quota=False,
error_code=None,
extra_data=None,
db=db,
)
# Update conversation state
wizard_state = dict(conversation.wizard_state)
wizard_state["branches"] = branches
await update_conversation(
conversation.id,
current_user.id,
{
"status": "scaffolding",
"wizard_state": wizard_state,
"question_rounds": conversation.question_rounds + 1,
},
db,
)
await db.commit()
return AIScaffoldResponse(
conversation_id=conversation.id,
branches=branches,
status="scaffolding",
)
@router.post("/branch-detail", response_model=AIBranchDetailResponse)
@limiter.limit("10/minute")
async def branch_detail(
request: Request,
data: AIBranchDetailRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Stage 3: AI generates detailed nodes for one branch."""
_require_ai_enabled()
conversation = await get_conversation(
data.conversation_id, current_user.id, db
)
if conversation.question_rounds >= settings.AI_MAX_CALLS_PER_FLOW:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Maximum AI calls per flow exceeded",
)
wizard_state = conversation.wizard_state
existing_branches = [
b.get("name", "") for b in wizard_state.get("branches", [])
]
plan = await get_user_plan(current_user.account_id, db)
try:
branch_tree, input_tokens, output_tokens, cost = (
await generate_branch_detail(
wizard_state,
data.branch_name,
existing_branches,
)
)
except ValueError as e:
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=conversation.id,
generation_type="branch_detail",
tier=plan,
input_tokens=0,
output_tokens=0,
estimated_cost=0,
succeeded=False,
counts_toward_quota=False,
error_code="invalid_output",
extra_data={"error": str(e), "branch_name": data.branch_name},
db=db,
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"AI returned invalid output: {e}",
)
except Exception as e:
logger.exception("AI branch_detail failed: %s: %s", type(e).__name__, e)
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=conversation.id,
generation_type="branch_detail",
tier=plan,
input_tokens=0,
output_tokens=0,
estimated_cost=0,
succeeded=False,
counts_toward_quota=False,
error_code=type(e).__name__,
extra_data={"error": str(e), "branch_name": data.branch_name},
db=db,
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider error ({type(e).__name__}). Please try again.",
)
# Record successful usage
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=conversation.id,
generation_type="branch_detail",
tier=plan,
input_tokens=input_tokens,
output_tokens=output_tokens,
estimated_cost=cost,
succeeded=True,
counts_toward_quota=False,
error_code=None,
extra_data={"branch_name": data.branch_name},
db=db,
)
# Update conversation
await update_conversation(
conversation.id,
current_user.id,
{
"status": "detailing",
"question_rounds": conversation.question_rounds + 1,
},
db,
)
await db.commit()
return AIBranchDetailResponse(
conversation_id=conversation.id,
branch_name=data.branch_name,
steps=branch_tree,
status="detailing",
)
@router.post("/assemble", response_model=AIAssembleResponse)
@limiter.limit("10/minute")
async def assemble(
request: Request,
data: AIAssembleRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Stage 4: Assemble selected branches into a complete tree (no AI calls)."""
conversation = await get_conversation(
data.conversation_id, current_user.id, db
)
wizard_state = conversation.wizard_state
branches_for_assembly = [b.model_dump() for b in data.selected_branches]
try:
tree_structure, name, description, stats = assemble_tree(
wizard_state, branches_for_assembly
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e),
)
# Record quota-consuming usage on successful assembly
plan = await get_user_plan(current_user.account_id, db)
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=conversation.id,
generation_type="tree",
tier=plan,
input_tokens=0,
output_tokens=0,
estimated_cost=0,
succeeded=True,
counts_toward_quota=True,
error_code=None,
extra_data={"stats": stats},
db=db,
)
# Update conversation with assembled tree
await update_conversation(
conversation.id,
current_user.id,
{
"status": "completed",
"generated_tree": tree_structure,
},
db,
)
await db.commit()
return AIAssembleResponse(
tree_structure=tree_structure,
suggested_name=name,
suggested_description=description,
summary=stats,
status="completed",
)
@router.post("/session-to-flow", response_model=SessionToFlowResponse)
@limiter.limit("5/minute")
async def session_to_flow(
request: Request,
data: SessionToFlowRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Convert a completed troubleshooting session into a reusable procedural flow."""
_require_ai_enabled()
# Check AI quota
allowed, quota_status = await check_ai_quota(
user_id=current_user.id,
account_id=current_user.account_id,
db=db,
billing_anchor=current_user.ai_billing_cycle_anchor_at,
is_super_admin=current_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 build limit exceeded ({quota_status['deny_reason']})",
"reset_at": quota_status.get(reset_key),
"quota": quota_status,
},
)
plan = await get_user_plan(current_user.account_id, db)
try:
result = await generate_flow_from_session(
session_id=data.session_id,
user_id=current_user.id,
account_id=current_user.account_id,
db=db,
)
except ValueError as e:
logger.warning("session_to_flow validation error: %s", e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception as e:
logger.exception("session_to_flow failed: %s", e)
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=None,
generation_type="session_to_flow",
tier=plan,
input_tokens=0,
output_tokens=0,
estimated_cost=0,
succeeded=False,
counts_toward_quota=False,
error_code=type(e).__name__,
extra_data={"session_id": data.session_id, "error": str(e)},
db=db,
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to generate flow: {type(e).__name__}. Please try again.",
)
# Record successful quota-consuming usage
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=None,
generation_type="session_to_flow",
tier=plan,
input_tokens=0,
output_tokens=0,
estimated_cost=0,
succeeded=True,
counts_toward_quota=True,
error_code=None,
extra_data={"session_id": data.session_id},
db=db,
)
await db.commit()
return SessionToFlowResponse(**result)