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>
This commit is contained in:
2026-03-18 14:27:36 +00:00
parent 44eb48e457
commit 5494816b06
29 changed files with 3647 additions and 5 deletions

View File

@@ -20,6 +20,8 @@ from app.models.ai_suggestion import AISuggestion # noqa: F401
from app.models.kb_import import KBImport, KBImportNode # noqa: F401
from app.models.script_template import ScriptCategory, ScriptTemplate, ScriptGeneration # noqa: F401
from app.models.psa_connection import PsaConnection # noqa: F401
from app.models.ai_session import AISession # noqa: F401
from app.models.ai_session_step import AISessionStep # noqa: F401
from app.models.psa_post_log import PsaPostLog # noqa: F401
from app.models.psa_member_mapping import PsaMemberMapping # noqa: F401
from app.core.config import settings

View File

@@ -0,0 +1,129 @@
"""add ai_sessions and ai_session_steps tables
Revision ID: f1a2b3c4d5e6
Revises: ee98013dd18c
Create Date: 2026-03-18
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
# revision identifiers, used by Alembic.
revision = "f1a2b3c4d5e6"
down_revision = "ee98013dd18c"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── ai_sessions table ──
op.create_table(
"ai_sessions",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="SET NULL"), nullable=True, index=True),
# Intake
sa.Column("intake_type", sa.String(20), nullable=False, server_default="free_text"),
sa.Column("intake_content", JSONB, nullable=False, server_default="{}"),
sa.Column("problem_summary", sa.Text, nullable=True),
sa.Column("problem_domain", sa.String(100), nullable=True),
# Session state
sa.Column("status", sa.String(20), nullable=False, server_default="active", index=True),
sa.Column("confidence_tier", sa.String(20), nullable=False, server_default="discovery"),
sa.Column("confidence_score", sa.Float, nullable=False, server_default="0.0"),
# Flow matching
sa.Column("matched_flow_id", UUID(as_uuid=True), sa.ForeignKey("trees.id", ondelete="SET NULL"), nullable=True),
sa.Column("match_score", sa.Float, nullable=True),
# PSA link
sa.Column("psa_ticket_id", sa.String(100), nullable=True),
sa.Column("psa_connection_id", UUID(as_uuid=True), sa.ForeignKey("psa_connections.id", ondelete="SET NULL"), nullable=True),
sa.Column("ticket_data", JSONB, nullable=True),
# Resolution / Escalation
sa.Column("resolution_summary", sa.Text, nullable=True),
sa.Column("resolution_action", sa.Text, nullable=True),
sa.Column("escalation_reason", sa.Text, nullable=True),
sa.Column("escalation_package", JSONB, nullable=True),
sa.Column("escalated_to_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
# Feedback
sa.Column("session_rating", sa.Integer, nullable=True),
sa.Column("session_feedback", sa.Text, nullable=True),
# AI tracking
sa.Column("total_input_tokens", sa.Integer, nullable=False, server_default="0"),
sa.Column("total_output_tokens", sa.Integer, nullable=False, server_default="0"),
sa.Column("step_count", sa.Integer, nullable=False, server_default="0"),
# Timestamps
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
# LLM context
sa.Column("system_prompt_snapshot", sa.Text, nullable=True),
sa.Column("conversation_messages", JSONB, nullable=False, server_default="[]"),
# Check constraints
sa.CheckConstraint(
"intake_type IN ('free_text', 'psa_ticket', 'screenshot', 'log_paste', 'combined')",
name="ck_ai_sessions_intake_type",
),
sa.CheckConstraint(
"status IN ('active', 'paused', 'resolved', 'escalated', 'abandoned')",
name="ck_ai_sessions_status",
),
sa.CheckConstraint(
"confidence_tier IN ('guided', 'exploring', 'discovery')",
name="ck_ai_sessions_confidence_tier",
),
)
# ── ai_session_steps table ──
op.create_table(
"ai_session_steps",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("session_id", UUID(as_uuid=True), sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("step_order", sa.Integer, nullable=False),
sa.Column("step_type", sa.String(30), nullable=False),
# Content
sa.Column("content", JSONB, nullable=False, server_default="{}"),
sa.Column("context_message", sa.Text, nullable=True),
# Options
sa.Column("options_presented", JSONB, nullable=True),
# Engineer response
sa.Column("selected_option", sa.String(500), nullable=True),
sa.Column("free_text_input", sa.Text, nullable=True),
sa.Column("was_free_text", sa.Boolean, nullable=False, server_default="false"),
sa.Column("was_skipped", sa.Boolean, nullable=False, server_default="false"),
# Action results
sa.Column("action_result", JSONB, nullable=True),
# Script generation link
sa.Column("script_generation_id", UUID(as_uuid=True), sa.ForeignKey("script_generations.id", ondelete="SET NULL"), nullable=True),
# AI internals
sa.Column("confidence_at_step", sa.Float, nullable=False, server_default="0.0"),
sa.Column("ai_reasoning", sa.Text, nullable=True),
sa.Column("input_tokens", sa.Integer, nullable=False, server_default="0"),
sa.Column("output_tokens", sa.Integer, nullable=False, server_default="0"),
# Timestamps
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("responded_at", sa.DateTime(timezone=True), nullable=True),
# Check constraint
sa.CheckConstraint(
"step_type IN ('question', 'action', 'script_generation', 'verification', "
"'info_request', 'note', 'intake_analysis')",
name="ck_ai_session_steps_step_type",
),
)
# ── Add flow matching columns to trees table ──
op.add_column("trees", sa.Column("origin", sa.String(20), nullable=True, comment="manual | ai_generated | ai_enhanced"))
op.add_column("trees", sa.Column("source_session_id", UUID(as_uuid=True), nullable=True))
op.add_column("trees", sa.Column("match_keywords", JSONB, nullable=True, server_default="[]"))
op.add_column("trees", sa.Column("success_rate", sa.Float, nullable=True))
op.add_column("trees", sa.Column("last_matched_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("trees", "last_matched_at")
op.drop_column("trees", "success_rate")
op.drop_column("trees", "match_keywords")
op.drop_column("trees", "source_session_id")
op.drop_column("trees", "origin")
op.drop_table("ai_session_steps")
op.drop_table("ai_sessions")

View File

@@ -0,0 +1,411 @@
"""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()

View File

@@ -21,6 +21,7 @@ from app.api.endpoints import integrations
from app.api.endpoints import onboarding
from app.api.endpoints import branding
from app.api.endpoints import supporting_data
from app.api.endpoints import ai_sessions
api_router = APIRouter()
@@ -67,3 +68,4 @@ api_router.include_router(integrations.router)
api_router.include_router(onboarding.router)
api_router.include_router(branding.router)
api_router.include_router(supporting_data.router)
api_router.include_router(ai_sessions.router)

View File

@@ -36,6 +36,8 @@ from .survey_response import SurveyResponse
from .survey_invite import SurveyInvite
from .kb_import import KBImport, KBImportNode
from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration
from .ai_session import AISession
from .ai_session_step import AISessionStep
from .psa_connection import PsaConnection
from .psa_post_log import PsaPostLog
from .psa_member_mapping import PsaMemberMapping
@@ -90,6 +92,8 @@ __all__ = [
"ScriptCategory",
"ScriptTemplate",
"ScriptGeneration",
"AISession",
"AISessionStep",
"PsaConnection",
"PsaPostLog",
"PsaMemberMapping",

View File

@@ -0,0 +1,204 @@
"""AI-powered troubleshooting session model.
Represents a complete FlowPilot interaction from intake to resolution/escalation.
This is the central entity of the FlowPilot-First pivot.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Float, CheckConstraint
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.team import Team
from app.models.account import Account
from app.models.tree import Tree
from app.models.psa_connection import PsaConnection
class AISession(Base):
"""A FlowPilot-guided troubleshooting session.
Lifecycle: active → resolved | escalated | abandoned
Sessions may be paused and resumed (e.g., escalation handoff).
"""
__tablename__ = "ai_sessions"
__table_args__ = (
CheckConstraint(
"intake_type IN ('free_text', 'psa_ticket', 'screenshot', 'log_paste', 'combined')",
name="ck_ai_sessions_intake_type",
),
CheckConstraint(
"status IN ('active', 'paused', 'resolved', 'escalated', 'abandoned')",
name="ck_ai_sessions_status",
),
CheckConstraint(
"confidence_tier IN ('guided', 'exploring', 'discovery')",
name="ck_ai_sessions_confidence_tier",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("teams.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# ── Intake ──
intake_type: Mapped[str] = mapped_column(
String(20), nullable=False, default="free_text"
)
intake_content: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, default=dict,
comment="Original intake data: {text, image_urls, log_content, ticket_data}",
)
problem_summary: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="AI-generated one-line problem summary from intake",
)
problem_domain: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True,
comment="Classified domain: active_directory, networking, m365, hardware, etc.",
)
# ── Session state ──
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="active", index=True,
)
confidence_tier: Mapped[str] = mapped_column(
String(20), nullable=False, default="discovery",
comment="Current AI confidence: guided (>80%), exploring (40-80%), discovery (<40%)",
)
confidence_score: Mapped[float] = mapped_column(
Float, nullable=False, default=0.0,
comment="Numeric confidence 0.0-1.0 for internal tracking",
)
# ── Flow matching ──
matched_flow_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="SET NULL"),
nullable=True,
comment="If following an existing flow, which one",
)
match_score: Mapped[Optional[float]] = mapped_column(
Float, nullable=True,
comment="Similarity score of the matched flow (0.0-1.0)",
)
# ── PSA link ──
psa_ticket_id: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True,
comment="External PSA ticket ID if session was started from a ticket",
)
psa_connection_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("psa_connections.id", ondelete="SET NULL"),
nullable=True,
)
ticket_data: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True,
comment="Snapshot of PSA ticket data at session start",
)
# ── Resolution / Escalation ──
resolution_summary: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="What fixed the issue (set on resolution)",
)
resolution_action: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="The specific action/step that resolved the issue",
)
escalation_reason: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="Why escalated (set on escalation)",
)
escalation_package: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True,
comment="Context package for receiving engineer: steps_tried, hypotheses, suggestions",
)
escalated_to_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
# ── Feedback ──
session_rating: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True,
comment="1-5 engineer feedback rating",
)
session_feedback: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="Optional feedback text from engineer",
)
# ── AI tracking ──
total_input_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
)
total_output_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
)
step_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
)
# ── Timestamps ──
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
resolved_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
)
# ── LLM conversation context ──
system_prompt_snapshot: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="Snapshot of the system prompt used (for debugging/training)",
)
conversation_messages: Mapped[list[dict[str, Any]]] = mapped_column(
JSONB, nullable=False, default=list,
comment="Full LLM message history for context continuity",
)
# ── Relationships ──
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
account: Mapped["Account"] = relationship("Account")
team: Mapped[Optional["Team"]] = relationship("Team")
matched_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[matched_flow_id])
escalated_to: Mapped[Optional["User"]] = relationship("User", foreign_keys=[escalated_to_id])
psa_connection: Mapped[Optional["PsaConnection"]] = relationship("PsaConnection")
steps: Mapped[list["AISessionStep"]] = relationship(
"AISessionStep", back_populates="session",
cascade="all, delete-orphan",
order_by="AISessionStep.step_order",
)

View File

@@ -0,0 +1,133 @@
"""AI session step model.
Every interaction within an AI session is captured as a step.
Steps are the raw material that becomes flow nodes in the Knowledge Flywheel.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float, CheckConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.ai_session import AISession
from app.models.script_template import ScriptGeneration
class AISessionStep(Base):
"""A single interaction step within a FlowPilot session.
Step types:
- question: FlowPilot asks a diagnostic question with options
- action: FlowPilot suggests an action for the engineer to perform
- script_generation: FlowPilot invokes the Script Generator
- verification: FlowPilot asks engineer to verify a condition
- info_request: FlowPilot asks engineer to gather specific data
- note: Engineer or FlowPilot adds a contextual note
- intake_analysis: Initial analysis of the intake content
"""
__tablename__ = "ai_session_steps"
__table_args__ = (
CheckConstraint(
"step_type IN ('question', 'action', 'script_generation', 'verification', "
"'info_request', 'note', 'intake_analysis')",
name="ck_ai_session_steps_step_type",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ai_sessions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
step_order: Mapped[int] = mapped_column(
Integer, nullable=False,
comment="Sequential position in the session (0-indexed)",
)
step_type: Mapped[str] = mapped_column(
String(30), nullable=False,
)
# ── Content presented to engineer ──
content: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, default=dict,
comment="The question/action content rendered in the session UI",
)
context_message: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="Why FlowPilot is asking this (shown above the question)",
)
# ── Options (for question steps) ──
options_presented: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(
JSONB, nullable=True,
comment="Array of {label, value, followup_hint} options shown to engineer",
)
# ── Engineer response ──
selected_option: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True,
comment="Which option the engineer selected (value field)",
)
free_text_input: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="If engineer typed a custom response instead of selecting an option",
)
was_free_text: Mapped[bool] = mapped_column(
default=False,
comment="True if the engineer used the free-text escape hatch",
)
was_skipped: Mapped[bool] = mapped_column(
default=False,
comment="True if engineer selected 'I don't know / Can't check'",
)
# ── Action results ──
action_result: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True,
comment="Outcome of action step: {success: bool, details: str, next_hint: str}",
)
# ── Script generation link ──
script_generation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("script_generations.id", ondelete="SET NULL"),
nullable=True,
)
# ── AI internals ──
confidence_at_step: Mapped[float] = mapped_column(
Float, nullable=False, default=0.0,
comment="FlowPilot confidence level at this point (0.0-1.0)",
)
ai_reasoning: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="Why FlowPilot chose this step (internal, for debugging/training)",
)
input_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
)
output_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
)
# ── Timestamps ──
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
responded_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="When the engineer responded to this step",
)
# ── Relationships ──
session: Mapped["AISession"] = relationship("AISession", back_populates="steps")
script_generation: Mapped[Optional["ScriptGeneration"]] = relationship("ScriptGeneration")

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, Any, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Index, CheckConstraint
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Float, Index, CheckConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
@@ -161,6 +161,25 @@ class Tree(Base):
comment="Provenance metadata from .rfflow file import"
)
# Flow matching (FlowPilot AI sessions)
origin: Mapped[Optional[str]] = mapped_column(
String(20), nullable=True,
comment="manual | ai_generated | ai_enhanced"
)
source_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), nullable=True,
)
match_keywords: Mapped[Optional[list[Any]]] = mapped_column(
JSONB, nullable=True,
comment="Keywords for FlowPilot flow matching"
)
success_rate: Mapped[Optional[float]] = mapped_column(
Float, nullable=True,
)
last_matched_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
)
# Relationships
author: Mapped[Optional["User"]] = relationship("User", foreign_keys=[author_id], back_populates="trees")
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees")

View File

@@ -0,0 +1,171 @@
"""Pydantic schemas for FlowPilot AI sessions."""
from __future__ import annotations
from typing import Optional, Any
from uuid import UUID
from datetime import datetime
from pydantic import BaseModel, Field
# ── Intake ──
class AISessionCreateRequest(BaseModel):
"""Start a new FlowPilot session."""
intake_type: str = Field(
"free_text",
pattern="^(free_text|psa_ticket|screenshot|log_paste|combined)$",
)
intake_content: dict[str, Any] = Field(
...,
description=(
"Intake payload. Shape depends on intake_type: "
"{text: str} for free_text, "
"{text?: str, image_urls?: list[str]} for screenshot, "
"{text?: str, log_content?: str} for log_paste, "
"{ticket_id: str, psa_connection_id: str} for psa_ticket, "
"any combination for combined."
),
)
psa_ticket_id: Optional[str] = None
psa_connection_id: Optional[UUID] = None
class AISessionCreateResponse(BaseModel):
"""Response after starting a session — includes the first FlowPilot step."""
session_id: UUID
status: str
confidence_tier: str
problem_summary: str | None = None
problem_domain: str | None = None
matched_flow_id: UUID | None = None
matched_flow_name: str | None = None
match_score: float | None = None
first_step: AISessionStepResponse
# ── Step interaction ──
class StepOptionSchema(BaseModel):
"""A selectable option presented to the engineer."""
label: str
value: str
followup_hint: str | None = None
class AISessionStepResponse(BaseModel):
"""A FlowPilot step rendered in the session UI."""
step_id: UUID
step_order: int
step_type: str
content: dict[str, Any]
context_message: str | None = None
options: list[StepOptionSchema] = []
allow_free_text: bool = True
allow_skip: bool = True
confidence_tier: str
confidence_score: float
model_config = {"from_attributes": True}
class StepResponseRequest(BaseModel):
"""Engineer's response to a FlowPilot step."""
selected_option: str | None = None
free_text_input: str | None = None
was_skipped: bool = False
action_result: dict[str, Any] | None = None
class StepResponseResponse(BaseModel):
"""FlowPilot's next step after processing the engineer's response."""
session_id: UUID
status: str
confidence_tier: str
confidence_score: float
next_step: AISessionStepResponse | None = None
resolution_suggested: bool = False
resolution_summary: str | None = None
# ── Resolution / Escalation ──
class ResolveSessionRequest(BaseModel):
"""Close a session as resolved."""
resolution_summary: str = Field(..., min_length=5, max_length=2000)
resolution_action: str | None = None
session_rating: int | None = Field(None, ge=1, le=5)
session_feedback: str | None = None
class EscalateSessionRequest(BaseModel):
"""Escalate a session to another engineer."""
escalation_reason: str = Field(..., min_length=5, max_length=2000)
escalated_to_id: UUID | None = None
class DocumentationStep(BaseModel):
"""A step in the documentation trail."""
step_number: int
step_type: str
description: str
engineer_response: str | None = None
outcome: str | None = None
class SessionDocumentation(BaseModel):
"""Auto-generated session documentation."""
problem_summary: str
problem_domain: str | None = None
intake_summary: str
diagnostic_steps: list[DocumentationStep]
resolution_summary: str | None = None
escalation_reason: str | None = None
total_steps: int
duration_display: str | None = None
generated_at: datetime
class SessionCloseResponse(BaseModel):
"""Response after resolving or escalating."""
session_id: UUID
status: str
documentation: SessionDocumentation
class RateSessionRequest(BaseModel):
"""Submit post-session rating."""
rating: int = Field(..., ge=1, le=5)
feedback: str | None = None
# ── List / Detail ──
class AISessionSummary(BaseModel):
"""Compact session for list views."""
id: UUID
status: str
intake_type: str
problem_summary: str | None = None
problem_domain: str | None = None
confidence_tier: str
step_count: int
session_rating: int | None = None
created_at: datetime
resolved_at: datetime | None = None
model_config = {"from_attributes": True}
class AISessionDetail(AISessionSummary):
"""Full session detail with steps."""
intake_content: dict[str, Any]
matched_flow_id: UUID | None = None
match_score: float | None = None
resolution_summary: str | None = None
resolution_action: str | None = None
escalation_reason: str | None = None
session_feedback: str | None = None
steps: list[AISessionStepResponse] = []
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,278 @@
"""Flow Matching Engine v1 — find existing flows relevant to an AI session's intake.
Combines keyword matching, semantic search (via RAG embeddings), and recency
scoring to rank flows. Deliberately simple for v1; v2 (Phase 3) adds deeper
semantic matching.
Scoring weights: semantic 0.5, keyword 0.3, recency 0.2.
Threshold: only return matches with composite score > 0.5.
"""
import logging
from datetime import datetime, timezone, timedelta
from typing import Any, Optional
from uuid import UUID
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tree import Tree
from app.services.rag_service import search as rag_search
logger = logging.getLogger(__name__)
# Scoring weights
SEMANTIC_WEIGHT = 0.5
KEYWORD_WEIGHT = 0.3
RECENCY_WEIGHT = 0.2
# Only return matches above this composite score
SCORE_THRESHOLD = 0.5
async def find_matches(
intake_text: str,
problem_domain: Optional[str],
account_id: UUID,
db: AsyncSession,
limit: int = 5,
) -> list[dict[str, Any]]:
"""Find existing flows that match the intake description.
Returns list of dicts sorted by composite score:
{tree_id, tree_name, score, match_reason}
"""
candidates: dict[str, dict[str, Any]] = {}
# 1. Semantic search via existing RAG embeddings
try:
rag_results = await rag_search(
query=intake_text,
account_id=account_id,
db=db,
limit=10,
)
for r in rag_results:
tree_id = str(r["tree_id"])
similarity = r.get("similarity", 0.0)
if tree_id not in candidates:
candidates[tree_id] = {
"tree_id": tree_id,
"tree_name": r["tree_name"],
"semantic_score": similarity,
"keyword_score": 0.0,
"recency_score": 0.0,
"match_reasons": [],
}
else:
# Take the best semantic score across chunks
candidates[tree_id]["semantic_score"] = max(
candidates[tree_id]["semantic_score"], similarity
)
if similarity > 0.5:
candidates[tree_id]["match_reasons"].append(
f"semantic match ({similarity:.0%})"
)
except Exception as e:
logger.warning("Semantic search failed during flow matching: %s", e)
# 2. Keyword matching against trees.match_keywords
try:
keyword_matches = await _keyword_match(intake_text, account_id, db)
for km in keyword_matches:
tree_id = str(km["tree_id"])
if tree_id not in candidates:
candidates[tree_id] = {
"tree_id": tree_id,
"tree_name": km["tree_name"],
"semantic_score": 0.0,
"keyword_score": km["score"],
"recency_score": 0.0,
"match_reasons": [],
}
else:
candidates[tree_id]["keyword_score"] = km["score"]
if km["score"] > 0.3:
candidates[tree_id]["match_reasons"].append(
f"keyword match: {', '.join(km.get('matched_keywords', []))}"
)
except Exception as e:
logger.warning("Keyword matching failed: %s", e)
# 3. Category/domain match
if problem_domain:
try:
domain_matches = await _domain_match(problem_domain, account_id, db)
for dm in domain_matches:
tree_id = str(dm["tree_id"])
if tree_id not in candidates:
candidates[tree_id] = {
"tree_id": tree_id,
"tree_name": dm["tree_name"],
"semantic_score": 0.0,
"keyword_score": 0.2, # Small boost for domain match
"recency_score": 0.0,
"match_reasons": [],
}
else:
candidates[tree_id]["keyword_score"] = max(
candidates[tree_id]["keyword_score"], 0.2
)
candidates[tree_id]["match_reasons"].append(f"domain match: {problem_domain}")
except Exception as e:
logger.warning("Domain matching failed: %s", e)
# 4. Apply recency boost
now = datetime.now(timezone.utc)
for tree_id, candidate in candidates.items():
# We'll compute recency from the tree data if available
candidate["recency_score"] = 0.0 # Default, updated below
# Fetch recency data for all candidates
if candidates:
try:
recency_data = await _get_recency_scores(
list(candidates.keys()), db
)
for tree_id, recency_score in recency_data.items():
if tree_id in candidates:
candidates[tree_id]["recency_score"] = recency_score
except Exception as e:
logger.warning("Recency scoring failed: %s", e)
# 5. Compute composite scores and filter
results = []
for tree_id, c in candidates.items():
composite = (
c["semantic_score"] * SEMANTIC_WEIGHT
+ c["keyword_score"] * KEYWORD_WEIGHT
+ c["recency_score"] * RECENCY_WEIGHT
)
if composite > SCORE_THRESHOLD:
results.append({
"tree_id": UUID(tree_id),
"tree_name": c["tree_name"],
"score": round(composite, 3),
"match_reason": "; ".join(c["match_reasons"][:3]) if c["match_reasons"] else "composite match",
})
# Sort by score descending, take top N
results.sort(key=lambda x: x["score"], reverse=True)
return results[:limit]
async def _keyword_match(
intake_text: str,
account_id: UUID,
db: AsyncSession,
) -> list[dict[str, Any]]:
"""Match intake text against trees.match_keywords JSONB arrays.
Simple approach: tokenize intake text, check overlap with each tree's keywords.
"""
# Extract meaningful tokens from intake (lowercase, 3+ chars)
tokens = set()
for word in intake_text.lower().split():
cleaned = "".join(c for c in word if c.isalnum())
if len(cleaned) >= 3:
tokens.add(cleaned)
if not tokens:
return []
# Find trees with match_keywords set
result = await db.execute(
select(Tree.id, Tree.name, Tree.match_keywords)
.where(
Tree.account_id == account_id,
Tree.deleted_at.is_(None),
Tree.status == "published",
Tree.match_keywords.isnot(None),
)
)
rows = result.all()
matches = []
for row in rows:
tree_keywords = row.match_keywords or []
if not isinstance(tree_keywords, list):
continue
# Lowercase keywords for comparison
kw_lower = {str(kw).lower() for kw in tree_keywords}
# Calculate overlap
matched = tokens & kw_lower
if matched:
score = len(matched) / max(len(kw_lower), 1)
matches.append({
"tree_id": row.id,
"tree_name": row.name,
"score": min(score, 1.0),
"matched_keywords": list(matched)[:5],
})
return matches
async def _domain_match(
problem_domain: str,
account_id: UUID,
db: AsyncSession,
) -> list[dict[str, Any]]:
"""Find trees whose category matches the classified problem domain."""
result = await db.execute(
select(Tree.id, Tree.name)
.where(
Tree.account_id == account_id,
Tree.deleted_at.is_(None),
Tree.status == "published",
Tree.category.ilike(f"%{problem_domain}%"),
)
.limit(10)
)
rows = result.all()
return [{"tree_id": row.id, "tree_name": row.name} for row in rows]
async def _get_recency_scores(
tree_ids: list[str],
db: AsyncSession,
) -> dict[str, float]:
"""Calculate recency scores based on last_matched_at.
Trees matched within the last 7 days get full recency boost (0.2 → 1.0).
Trees matched within 30 days get partial boost.
Older or never-matched trees get 0.
"""
if not tree_ids:
return {}
result = await db.execute(
select(Tree.id, Tree.last_matched_at, Tree.success_rate)
.where(Tree.id.in_([UUID(tid) for tid in tree_ids]))
)
rows = result.all()
now = datetime.now(timezone.utc)
scores = {}
for row in rows:
tree_id = str(row.id)
if row.last_matched_at is None:
scores[tree_id] = 0.0
continue
days_since = (now - row.last_matched_at).days
if days_since <= 7:
recency = 1.0
elif days_since <= 30:
recency = 1.0 - ((days_since - 7) / 23) # Linear decay 7-30 days
else:
recency = 0.0
# Factor in success rate if available
if row.success_rate is not None:
recency *= row.success_rate
scores[tree_id] = max(0.0, min(1.0, recency))
return scores

View File

@@ -0,0 +1,737 @@
"""FlowPilot Engine — core LLM orchestration for AI troubleshooting sessions.
Manages structured diagnostic conversations: intake analysis, step generation,
confidence tracking, and auto-documentation. All LLM responses are structured
JSON validated against known output shapes.
"""
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any, Optional
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.ai_provider import get_ai_provider
from app.core.config import settings
from app.models.ai_session import AISession
from app.models.ai_session_step import AISessionStep
from app.schemas.ai_session import (
AISessionCreateRequest,
AISessionCreateResponse,
AISessionStepResponse,
StepOptionSchema,
StepResponseRequest,
StepResponseResponse,
ResolveSessionRequest,
EscalateSessionRequest,
SessionCloseResponse,
SessionDocumentation,
DocumentationStep,
)
logger = logging.getLogger(__name__)
# Maximum steps per session as a safety limit
MAX_STEPS_PER_SESSION = 30
STRUCTURED_OUTPUT_SCHEMA = """\
Your response MUST be a valid JSON object with one of these shapes:
1. Diagnostic question:
{"type": "question", "content": "Brief description", "reasoning": "Internal why", "context_message": "Shown to engineer", "options": [{"label": "Human text", "value": "machine_value", "followup_hint": "or null"}], "allow_free_text": true, "allow_skip": true, "confidence": 0.65}
2. Suggested action:
{"type": "action", "content": "What to do", "reasoning": "Internal why", "context_message": "Here's what to try", "action_type": "instruction | script_generation | verification | info_request", "expected_outcome": "What success looks like", "confidence": 0.78}
3. Resolution suggestion:
{"type": "resolution_suggestion", "content": "Summary of what we did", "reasoning": "Internal why", "resolution_summary": "Issue was caused by X, fixed by Y", "confidence": 0.92, "follow_up_recommendations": ["Monitor for 24 hours"]}\
"""
FLOWPILOT_SYSTEM_PROMPT = """\
You are FlowPilot, an expert MSP troubleshooting assistant embedded in ResolutionFlow. You guide engineers through structured diagnosis of IT issues.
## YOUR ROLE
- Conduct systematic troubleshooting through targeted questions and actions
- Start broad, narrow down based on responses
- Never guess — ask clarifying questions when uncertain
- Suggest specific, actionable steps the engineer can verify
- When confidence is high, suggest resolution; when low, keep investigating
## RESPONSE FORMAT
You MUST respond with ONLY a valid JSON object. No markdown, no prose, no code fences.
Every response must have a "type" field: "question", "action", or "resolution_suggestion".
{structured_output_schema}
## RULES
- Maximum 5 options per question. Options should be the most likely scenarios.
- Always include relevant context in context_message — explain WHY you're asking
- confidence is a float 0.0-1.0 reflecting how certain you are about the diagnosis path
- When multiple symptoms point to one root cause with >90% confidence, suggest resolution
- If you detect the engineer needs a PowerShell script, suggest a script_generation action
- Never suggest restarting or rebooting as a first step — diagnose first
- Be specific: "Check Event Viewer > System > source NTFS" not "check the logs"
{team_context}
{matched_flow_context}\
"""
INTAKE_CLASSIFICATION_PROMPT = """\
You are a triage classifier for IT support issues in an MSP environment.
Analyze the following intake and respond with ONLY a JSON object:
{
"problem_summary": "One-line summary of the issue (max 120 chars)",
"problem_domain": "One of: active_directory, networking, m365, hardware, endpoint, virtualization, security, backup, email, printing, cloud, other",
"key_symptoms": ["symptom1", "symptom2"],
"urgency": "low | medium | high | critical"
}\
"""
def _confidence_to_tier(confidence: float) -> str:
"""Map numeric confidence to tier label."""
if confidence >= 0.8:
return "guided"
elif confidence >= 0.4:
return "exploring"
return "discovery"
def _parse_structured_output(raw_text: str) -> dict[str, Any]:
"""Parse and validate structured JSON from LLM response.
Handles common LLM quirks: markdown fences, trailing commas, etc.
"""
text = raw_text.strip()
# Strip markdown code fences if present
if text.startswith("```"):
lines = text.split("\n")
# Remove first line (```json or ```) and last line (```)
lines = [l for l in lines if not l.strip().startswith("```")]
text = "\n".join(lines).strip()
try:
data = json.loads(text)
except json.JSONDecodeError as e:
logger.warning("Failed to parse LLM JSON output: %s — raw: %.200s", e, text)
raise ValueError(f"Invalid JSON from LLM: {e}") from e
if not isinstance(data, dict) or "type" not in data:
raise ValueError("LLM response missing required 'type' field")
valid_types = {"question", "action", "resolution_suggestion"}
if data["type"] not in valid_types:
raise ValueError(f"Unknown response type: {data['type']}")
return data
def _build_step_response(step: AISessionStep, session: AISession) -> AISessionStepResponse:
"""Convert a model step + session state into an API response."""
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 {}
return 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=session.confidence_score,
)
async def start_session(
request: AISessionCreateRequest,
user_id: UUID,
account_id: UUID,
team_id: Optional[UUID],
db: AsyncSession,
) -> AISessionCreateResponse:
"""Start a new FlowPilot session: classify intake, match flows, get first step."""
# 1. Classify intake via fast LLM call
intake_text = _extract_intake_text(request.intake_content)
classification = await _classify_intake(intake_text)
# 2. Try to match existing flows
from app.services.flow_matching_engine import find_matches
matches = await find_matches(
intake_text=intake_text,
problem_domain=classification.get("problem_domain"),
account_id=account_id,
db=db,
)
top_match = matches[0] if matches else None
matched_flow_id = top_match["tree_id"] if top_match else None
match_score = top_match["score"] if top_match else None
matched_flow_name = top_match["tree_name"] if top_match else None
# 3. Build system prompt
matched_flow_context = ""
if top_match and top_match.get("score", 0) > 0.5:
matched_flow_context = (
f"## MATCHED FLOW\n"
f"A similar flow exists: \"{top_match['tree_name']}\" "
f"(match score: {top_match['score']:.0%}). "
f"Use it as a guide but adapt to the specific situation."
)
system_prompt = FLOWPILOT_SYSTEM_PROMPT.format(
structured_output_schema=STRUCTURED_OUTPUT_SCHEMA,
team_context="", # Phase 2: team-specific context
matched_flow_context=matched_flow_context,
)
# 4. Build first user message from intake
user_message = _format_intake_message(request.intake_content, classification)
messages = [{"role": "user", "content": user_message}]
# 5. Call LLM for first diagnostic step
provider = get_ai_provider(settings.get_model_for_action("open_chat"))
raw_response, input_tokens, output_tokens = await provider.generate_json(
system_prompt=system_prompt,
messages=messages,
max_tokens=2048,
)
# Parse with retry on failure
try:
parsed = _parse_structured_output(raw_response)
except ValueError:
# Retry once with nudge
retry_messages = messages + [
{"role": "assistant", "content": raw_response},
{"role": "user", "content": "Please respond with ONLY valid JSON matching the required schema. No markdown or prose."},
]
raw_response, retry_in, retry_out = await provider.generate_json(
system_prompt=system_prompt,
messages=retry_messages,
max_tokens=2048,
)
input_tokens += retry_in
output_tokens += retry_out
parsed = _parse_structured_output(raw_response)
confidence = parsed.get("confidence", 0.0)
confidence_tier = _confidence_to_tier(confidence)
# Initial confidence from match + classification
if top_match and top_match.get("score", 0) > 0.8:
confidence_tier = "guided"
confidence = max(confidence, 0.8)
# 6. Create session
session = AISession(
id=uuid.uuid4(),
user_id=user_id,
account_id=account_id,
team_id=team_id,
intake_type=request.intake_type,
intake_content=request.intake_content,
problem_summary=classification.get("problem_summary"),
problem_domain=classification.get("problem_domain"),
status="active",
confidence_tier=confidence_tier,
confidence_score=confidence,
matched_flow_id=matched_flow_id,
match_score=match_score,
psa_ticket_id=request.psa_ticket_id,
psa_connection_id=request.psa_connection_id,
total_input_tokens=input_tokens,
total_output_tokens=output_tokens,
step_count=1,
system_prompt_snapshot=system_prompt,
conversation_messages=[
{"role": "user", "content": user_message},
{"role": "assistant", "content": raw_response},
],
)
db.add(session)
# 7. Create first step
step = _create_step_from_parsed(
session_id=session.id,
step_order=0,
parsed=parsed,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
db.add(step)
await db.flush()
return AISessionCreateResponse(
session_id=session.id,
status=session.status,
confidence_tier=session.confidence_tier,
problem_summary=session.problem_summary,
problem_domain=session.problem_domain,
matched_flow_id=matched_flow_id,
matched_flow_name=matched_flow_name,
match_score=match_score,
first_step=_build_step_response(step, session),
)
async def process_response(
session_id: UUID,
request: StepResponseRequest,
user_id: UUID,
db: AsyncSession,
) -> StepResponseResponse:
"""Process an engineer's response and generate the next FlowPilot step."""
session = await _load_session(session_id, user_id, db)
if session.status != "active":
raise ValueError(f"Session is {session.status}, not active")
if session.step_count >= MAX_STEPS_PER_SESSION:
raise ValueError("Maximum steps reached for this session")
# Update the current (latest) step with engineer's response
latest_step = session.steps[-1] if session.steps else None
if latest_step and latest_step.responded_at is None:
latest_step.selected_option = request.selected_option
latest_step.free_text_input = request.free_text_input
latest_step.was_free_text = bool(request.free_text_input and not request.selected_option)
latest_step.was_skipped = request.was_skipped
latest_step.action_result = request.action_result
latest_step.responded_at = datetime.now(timezone.utc)
# Build the conversation message for the engineer's response
response_text = _format_engineer_response(request)
session.conversation_messages = session.conversation_messages + [
{"role": "user", "content": response_text}
]
# Call LLM with full conversation
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)
# Append assistant response to conversation
session.conversation_messages = session.conversation_messages + [
{"role": "assistant", "content": raw_response}
]
# Update session confidence
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
# Create new step
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(step)
await db.flush()
# Check if resolution was suggested
resolution_suggested = parsed["type"] == "resolution_suggestion"
resolution_summary = parsed.get("resolution_summary") if resolution_suggested else None
return StepResponseResponse(
session_id=session.id,
status=session.status,
confidence_tier=session.confidence_tier,
confidence_score=session.confidence_score,
next_step=_build_step_response(step, session),
resolution_suggested=resolution_suggested,
resolution_summary=resolution_summary,
)
async def resolve_session(
session_id: UUID,
request: ResolveSessionRequest,
user_id: UUID,
db: AsyncSession,
) -> SessionCloseResponse:
"""Close a session as resolved and generate documentation."""
session = await _load_session(session_id, user_id, db)
if session.status not in ("active", "paused"):
raise ValueError(f"Cannot resolve session in status: {session.status}")
session.status = "resolved"
session.resolved_at = datetime.now(timezone.utc)
session.resolution_summary = request.resolution_summary
session.resolution_action = request.resolution_action
if request.session_rating is not None:
session.session_rating = request.session_rating
if request.session_feedback is not None:
session.session_feedback = request.session_feedback
documentation = _generate_documentation(session)
await db.flush()
return SessionCloseResponse(
session_id=session.id,
status=session.status,
documentation=documentation,
)
async def escalate_session(
session_id: UUID,
request: EscalateSessionRequest,
user_id: UUID,
db: AsyncSession,
) -> SessionCloseResponse:
"""Escalate a session to another engineer."""
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)
session.escalation_reason = request.escalation_reason
session.escalated_to_id = request.escalated_to_id
# Build escalation package
session.escalation_package = _build_escalation_package(session)
documentation = _generate_documentation(session)
await db.flush()
return SessionCloseResponse(
session_id=session.id,
status=session.status,
documentation=documentation,
)
async def rate_session(
session_id: UUID,
rating: int,
feedback: Optional[str],
user_id: UUID,
db: AsyncSession,
) -> None:
"""Submit post-session rating."""
session = await _load_session(session_id, user_id, db)
session.session_rating = rating
session.session_feedback = feedback
await db.flush()
async def get_session_documentation(
session_id: UUID,
user_id: UUID,
db: AsyncSession,
) -> SessionDocumentation:
"""Get auto-generated documentation for a session."""
session = await _load_session(session_id, user_id, db)
return _generate_documentation(session)
# ── Internal helpers ──
async def _load_session(
session_id: UUID,
user_id: UUID,
db: AsyncSession,
) -> AISession:
"""Load session with steps, verifying ownership."""
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 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")
return session
async def _classify_intake(intake_text: str) -> dict[str, Any]:
"""Quick LLM call to classify intake content."""
try:
provider = get_ai_provider(settings.get_model_for_action("quick_action"))
raw, _, _ = await provider.generate_json(
system_prompt=INTAKE_CLASSIFICATION_PROMPT,
messages=[{"role": "user", "content": intake_text}],
max_tokens=512,
)
return json.loads(raw.strip())
except Exception as e:
logger.warning("Intake classification failed: %s", e)
return {
"problem_summary": intake_text[:120],
"problem_domain": "other",
"key_symptoms": [],
"urgency": "medium",
}
def _extract_intake_text(intake_content: dict[str, Any]) -> str:
"""Extract searchable text from intake content."""
parts = []
if text := intake_content.get("text"):
parts.append(text)
if log := intake_content.get("log_content"):
parts.append(f"Log output:\n{log}")
if ticket := intake_content.get("ticket_data"):
if isinstance(ticket, dict):
parts.append(f"Ticket: {ticket.get('summary', '')}")
return "\n\n".join(parts) if parts else str(intake_content)
def _format_intake_message(
intake_content: dict[str, Any],
classification: dict[str, Any],
) -> str:
"""Format intake + classification into the first user message."""
parts = ["I need help troubleshooting an issue."]
if text := intake_content.get("text"):
parts.append(f"\n**Problem description:**\n{text}")
if log := intake_content.get("log_content"):
parts.append(f"\n**Log output:**\n```\n{log}\n```")
if summary := classification.get("problem_summary"):
parts.append(f"\n**Classified as:** {summary}")
if domain := classification.get("problem_domain"):
parts.append(f"**Domain:** {domain}")
symptoms = classification.get("key_symptoms", [])
if symptoms:
parts.append(f"**Key symptoms:** {', '.join(symptoms)}")
return "\n".join(parts)
def _format_engineer_response(request: StepResponseRequest) -> str:
"""Format engineer's step response into a conversation message."""
if request.was_skipped:
return "I can't check this right now / I don't know."
parts = []
if request.selected_option:
parts.append(f"Selected: {request.selected_option}")
if request.free_text_input:
parts.append(request.free_text_input)
if request.action_result:
result = request.action_result
success = "succeeded" if result.get("success") else "did not work"
parts.append(f"Action {success}.")
if details := result.get("details"):
parts.append(f"Details: {details}")
return "\n".join(parts) if parts else "No response provided."
def _create_step_from_parsed(
session_id: UUID,
step_order: int,
parsed: dict[str, Any],
input_tokens: int,
output_tokens: int,
) -> AISessionStep:
"""Create an AISessionStep from parsed LLM output."""
step_type = parsed["type"]
if step_type == "resolution_suggestion":
step_type = "action" # Store as action in DB, UI distinguishes via content
# Build content dict (everything the UI needs to render)
content = {
"text": parsed.get("content", ""),
"type": parsed["type"],
}
if parsed["type"] == "action":
content["action_type"] = parsed.get("action_type", "instruction")
content["expected_outcome"] = parsed.get("expected_outcome")
elif parsed["type"] == "resolution_suggestion":
content["resolution_summary"] = parsed.get("resolution_summary")
content["follow_up_recommendations"] = parsed.get("follow_up_recommendations", [])
content["allow_free_text"] = False
content["allow_skip"] = False
# Extract options for question type
options = None
if parsed["type"] == "question" and "options" in parsed:
options = parsed["options"]
content["allow_free_text"] = parsed.get("allow_free_text", True)
content["allow_skip"] = parsed.get("allow_skip", True)
return AISessionStep(
id=uuid.uuid4(),
session_id=session_id,
step_order=step_order,
step_type=step_type if parsed["type"] != "resolution_suggestion" else "action",
content=content,
context_message=parsed.get("context_message"),
options_presented=options,
confidence_at_step=parsed.get("confidence", 0.0),
ai_reasoning=parsed.get("reasoning"),
input_tokens=input_tokens,
output_tokens=output_tokens,
)
def _generate_documentation(session: AISession) -> SessionDocumentation:
"""Generate structured documentation from a session's steps."""
diagnostic_steps = []
for step in session.steps:
content = step.content or {}
description = content.get("text", "")
# Determine engineer response
engineer_response = None
if step.was_skipped:
engineer_response = "Skipped"
elif step.selected_option:
# Find the label for the selected option
if step.options_presented:
for opt in step.options_presented:
if opt.get("value") == step.selected_option:
engineer_response = opt.get("label", step.selected_option)
break
else:
engineer_response = step.selected_option
else:
engineer_response = step.selected_option
elif step.free_text_input:
engineer_response = step.free_text_input
# Determine outcome
outcome = None
if step.action_result:
result = step.action_result
outcome = "Succeeded" if result.get("success") else "Did not resolve"
if details := result.get("details"):
outcome += f"{details}"
diagnostic_steps.append(DocumentationStep(
step_number=step.step_order + 1,
step_type=step.step_type,
description=description,
engineer_response=engineer_response,
outcome=outcome,
))
# Calculate duration
duration_display = None
if session.resolved_at and session.created_at:
delta = session.resolved_at - session.created_at
minutes = int(delta.total_seconds() / 60)
if minutes < 60:
duration_display = f"{minutes}m"
else:
hours = minutes // 60
remaining = minutes % 60
duration_display = f"{hours}h {remaining}m"
# Build intake summary
intake = session.intake_content or {}
intake_summary = intake.get("text", "")[:500]
if not intake_summary:
intake_summary = str(intake)[:500]
return SessionDocumentation(
problem_summary=session.problem_summary or "No summary available",
problem_domain=session.problem_domain,
intake_summary=intake_summary,
diagnostic_steps=diagnostic_steps,
resolution_summary=session.resolution_summary,
escalation_reason=session.escalation_reason,
total_steps=session.step_count,
duration_display=duration_display,
generated_at=datetime.now(timezone.utc),
)
def _build_escalation_package(session: AISession) -> dict[str, Any]:
"""Build context package for the receiving engineer."""
steps_tried = []
for step in session.steps:
content = step.content or {}
entry = {
"step_type": step.step_type,
"description": content.get("text", ""),
}
if step.selected_option:
entry["response"] = step.selected_option
elif step.free_text_input:
entry["response"] = step.free_text_input
elif step.was_skipped:
entry["response"] = "Skipped"
if step.action_result:
entry["action_result"] = step.action_result
steps_tried.append(entry)
return {
"problem_summary": session.problem_summary,
"problem_domain": session.problem_domain,
"intake_content": session.intake_content,
"confidence_at_escalation": session.confidence_score,
"steps_tried": steps_tried,
"escalation_reason": session.escalation_reason,
}

View File

@@ -0,0 +1,67 @@
import apiClient from './client'
import type {
AISessionCreateRequest,
AISessionCreateResponse,
StepResponseRequest,
StepResponseResponse,
ResolveSessionRequest,
EscalateSessionRequest,
SessionCloseResponse,
SessionDocumentation,
AISessionSummary,
AISessionDetail,
} from '@/types/ai-session'
export const aiSessionsApi = {
async createSession(data: AISessionCreateRequest): Promise<AISessionCreateResponse> {
const response = await apiClient.post<AISessionCreateResponse>('/ai-sessions', data)
return response.data
},
async respondToStep(sessionId: string, data: StepResponseRequest): Promise<StepResponseResponse> {
const response = await apiClient.post<StepResponseResponse>(
`/ai-sessions/${sessionId}/respond`,
data
)
return response.data
},
async resolveSession(sessionId: string, data: ResolveSessionRequest): Promise<SessionCloseResponse> {
const response = await apiClient.post<SessionCloseResponse>(
`/ai-sessions/${sessionId}/resolve`,
data
)
return response.data
},
async escalateSession(sessionId: string, data: EscalateSessionRequest): Promise<SessionCloseResponse> {
const response = await apiClient.post<SessionCloseResponse>(
`/ai-sessions/${sessionId}/escalate`,
data
)
return response.data
},
async listSessions(params?: { status?: string; skip?: number; limit?: number }): Promise<AISessionSummary[]> {
const response = await apiClient.get<AISessionSummary[]>('/ai-sessions', { params })
return response.data
},
async getSession(sessionId: string): Promise<AISessionDetail> {
const response = await apiClient.get<AISessionDetail>(`/ai-sessions/${sessionId}`)
return response.data
},
async getDocumentation(sessionId: string): Promise<SessionDocumentation> {
const response = await apiClient.get<SessionDocumentation>(
`/ai-sessions/${sessionId}/documentation`
)
return response.data
},
async rateSession(sessionId: string, data: { rating: number; feedback?: string }): Promise<void> {
await apiClient.post(`/ai-sessions/${sessionId}/rate`, data)
},
}
export default aiSessionsApi

View File

@@ -24,3 +24,4 @@ export { scriptsApi } from './scripts'
export { integrationsApi, sessionPsaApi } from './integrations'
export { sidebarApi } from './sidebar'
export { sessionToFlowApi } from './sessionToFlow'
export { aiSessionsApi } from './aiSessions'

View File

@@ -0,0 +1,60 @@
import { Link } from 'react-router-dom'
import { Clock, CheckCircle2, ArrowUpRight, AlertCircle, Pause } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { AISessionSummary } from '@/types/ai-session'
interface AISessionListItemProps {
session: AISessionSummary
}
const STATUS_CONFIG = {
active: { icon: Clock, color: 'text-primary', label: 'Active' },
paused: { icon: Pause, color: 'text-amber-400', label: 'Paused' },
resolved: { icon: CheckCircle2, color: 'text-emerald-400', label: 'Resolved' },
escalated: { icon: ArrowUpRight, color: 'text-amber-400', label: 'Escalated' },
abandoned: { icon: AlertCircle, color: 'text-[#5a6170]', label: 'Abandoned' },
} as const
export function AISessionListItem({ session }: AISessionListItemProps) {
const config = STATUS_CONFIG[session.status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.active
const StatusIcon = config.icon
return (
<Link
to={`/pilot/${session.id}`}
className="glass-card block p-4 transition-all"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{session.problem_summary || 'Untitled session'}
</p>
<div className="mt-1.5 flex items-center gap-3 flex-wrap">
{session.problem_domain && (
<span className="font-label rounded-md bg-primary/10 px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-primary">
{session.problem_domain}
</span>
)}
<span className={cn('flex items-center gap-1 text-xs', config.color)}>
<StatusIcon size={12} />
{config.label}
</span>
<span className="text-xs text-muted-foreground">
{session.step_count} steps
</span>
<span className="text-xs text-[#5a6170]">
{new Date(session.created_at).toLocaleDateString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
})}
</span>
</div>
</div>
{session.session_rating && (
<span className="font-label text-xs text-amber-400">
{'★'.repeat(session.session_rating)}
</span>
)}
</div>
</Link>
)
}

View File

@@ -0,0 +1,44 @@
import { cn } from '@/lib/utils'
interface ConfidenceIndicatorProps {
tier: string
score: number
className?: string
}
const TIER_CONFIG = {
guided: {
color: 'bg-emerald-400',
label: 'Proven path',
description: 'FlowPilot is following a known resolution path with high confidence.',
},
exploring: {
color: 'bg-amber-400',
label: 'Investigating',
description: 'FlowPilot is narrowing down the issue based on your responses.',
},
discovery: {
color: 'bg-violet-400',
label: 'New territory',
description: 'FlowPilot is exploring this issue — your responses help build the knowledge base.',
},
} as const
export function ConfidenceIndicator({ tier, score, className }: ConfidenceIndicatorProps) {
const config = TIER_CONFIG[tier as keyof typeof TIER_CONFIG] ?? TIER_CONFIG.discovery
return (
<div className={cn('group relative inline-flex items-center gap-2', className)}>
<span className={cn('h-2 w-2 rounded-full', config.color)} />
<span className="font-label text-xs text-muted-foreground">{config.label}</span>
{/* Tooltip */}
<div className="pointer-events-none absolute left-0 top-full z-50 mt-2 w-56 rounded-lg border border-border bg-card p-3 opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
<p className="text-xs text-muted-foreground">{config.description}</p>
<p className="mt-1 font-label text-[0.625rem] text-[#5a6170]">
Confidence: {Math.round(score * 100)}%
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,140 @@
import { useState } from 'react'
import { CheckCircle2, ArrowUpRight } from 'lucide-react'
import type { ResolveSessionRequest, EscalateSessionRequest, SessionDocumentation } from '@/types/ai-session'
interface FlowPilotActionBarProps {
canResolve: boolean
canEscalate: boolean
isProcessing: boolean
onResolve: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
onEscalate: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
}
export function FlowPilotActionBar({
canResolve,
canEscalate,
isProcessing,
onResolve,
onEscalate,
}: FlowPilotActionBarProps) {
const [showResolve, setShowResolve] = useState(false)
const [showEscalate, setShowEscalate] = useState(false)
const [resolutionSummary, setResolutionSummary] = useState('')
const [escalationReason, setEscalationReason] = useState('')
const [submitting, setSubmitting] = useState(false)
const handleResolve = async () => {
if (!resolutionSummary.trim() || resolutionSummary.length < 5) return
setSubmitting(true)
try {
await onResolve({ resolution_summary: resolutionSummary })
setShowResolve(false)
} finally {
setSubmitting(false)
}
}
const handleEscalate = async () => {
if (!escalationReason.trim() || escalationReason.length < 5) return
setSubmitting(true)
try {
await onEscalate({ escalation_reason: escalationReason })
setShowEscalate(false)
} finally {
setSubmitting(false)
}
}
return (
<>
{/* Bottom bar */}
<div
className="flex items-center gap-3 border-t px-5 py-3"
style={{ borderColor: 'var(--glass-border)', background: 'rgba(16, 17, 20, 0.8)', backdropFilter: 'blur(12px)' }}
>
<button
onClick={() => { setShowResolve(true); setShowEscalate(false) }}
disabled={!canResolve || isProcessing}
className="flex items-center gap-2 rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-400 hover:bg-emerald-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<CheckCircle2 size={16} />
Resolve
</button>
<button
onClick={() => { setShowEscalate(true); setShowResolve(false) }}
disabled={!canEscalate || isProcessing}
className="flex items-center gap-2 rounded-lg bg-amber-500/10 border border-amber-500/20 px-4 py-2 text-sm font-medium text-amber-400 hover:bg-amber-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<ArrowUpRight size={16} />
Escalate
</button>
</div>
{/* Resolve modal */}
{showResolve && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="glass-card-static w-full max-w-lg p-6">
<h3 className="font-heading text-lg font-semibold text-foreground mb-1">Resolve Session</h3>
<p className="text-sm text-muted-foreground mb-4">Summarize what fixed the issue. This will be included in the auto-generated documentation.</p>
<textarea
value={resolutionSummary}
onChange={(e) => setResolutionSummary(e.target.value)}
placeholder="What resolved the issue?"
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
rows={4}
autoFocus
/>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => setShowResolve(false)}
className="rounded-lg px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
<button
onClick={handleResolve}
disabled={resolutionSummary.length < 5 || submitting}
className="rounded-lg bg-emerald-500/20 border border-emerald-500/30 px-4 py-2 text-sm font-medium text-emerald-400 hover:bg-emerald-500/30 disabled:opacity-50 transition-colors"
>
{submitting ? 'Resolving...' : 'Resolve Session'}
</button>
</div>
</div>
</div>
)}
{/* Escalate modal */}
{showEscalate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="glass-card-static w-full max-w-lg p-6">
<h3 className="font-heading text-lg font-semibold text-foreground mb-1">Escalate Session</h3>
<p className="text-sm text-muted-foreground mb-4">Explain why this needs escalation. FlowPilot will package the context for the next engineer.</p>
<textarea
value={escalationReason}
onChange={(e) => setEscalationReason(e.target.value)}
placeholder="Why does this need to be escalated?"
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
rows={4}
autoFocus
/>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => setShowEscalate(false)}
className="rounded-lg px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
<button
onClick={handleEscalate}
disabled={escalationReason.length < 5 || submitting}
className="rounded-lg bg-amber-500/20 border border-amber-500/30 px-4 py-2 text-sm font-medium text-amber-400 hover:bg-amber-500/30 disabled:opacity-50 transition-colors"
>
{submitting ? 'Escalating...' : 'Escalate Session'}
</button>
</div>
</div>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,119 @@
import { useState } from 'react'
import { Sparkles, FileText, Terminal } from 'lucide-react'
import type { AISessionCreateRequest } from '@/types/ai-session'
interface FlowPilotIntakeProps {
onSubmit: (request: AISessionCreateRequest) => void
isLoading: boolean
}
export function FlowPilotIntake({ onSubmit, isLoading }: FlowPilotIntakeProps) {
const [text, setText] = useState('')
const [showLogs, setShowLogs] = useState(false)
const [logContent, setLogContent] = useState('')
const handleSubmit = () => {
if (!text.trim() && !logContent.trim()) return
const intake_content: Record<string, unknown> = {}
if (text.trim()) intake_content.text = text.trim()
if (logContent.trim()) intake_content.log_content = logContent.trim()
const intake_type = logContent.trim()
? text.trim() ? 'combined' : 'log_paste'
: 'free_text'
onSubmit({ intake_type, intake_content })
}
const hasContent = text.trim() || logContent.trim()
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10">
<Sparkles size={24} className="text-primary animate-pulse" />
</div>
<p className="text-sm font-medium text-foreground">Analyzing your issue...</p>
<p className="mt-1 text-xs text-muted-foreground">FlowPilot is classifying the problem and searching for relevant flows</p>
</div>
</div>
)
}
return (
<div className="flex items-start justify-center pt-[10vh]">
<div className="w-full max-w-2xl">
<div className="text-center mb-6">
<h1 className="font-heading text-2xl font-bold tracking-tight text-foreground">
What are you troubleshooting?
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Describe the issue, paste an error message, or paste log output
</p>
</div>
<div className="glass-card-static p-5 space-y-4">
{/* Main text area */}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="e.g. User can't access shared drive after password reset, getting 'Access Denied' in Event Viewer..."
className="w-full rounded-lg border border-border bg-card px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
rows={5}
autoFocus
/>
{/* Input type toggles */}
<div className="flex items-center gap-2">
<button
onClick={() => setShowLogs(!showLogs)}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
showLogs
? 'bg-primary/10 text-primary border border-primary/20'
: 'bg-card/50 text-muted-foreground border border-border hover:text-foreground'
}`}
>
<Terminal size={12} />
Paste Logs
</button>
<button
disabled
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium bg-card/50 text-[#5a6170] border border-border opacity-50 cursor-not-allowed"
title="Coming in Phase 2"
>
<FileText size={12} />
Pull from Ticket
</button>
</div>
{/* Log paste area */}
{showLogs && (
<textarea
value={logContent}
onChange={(e) => setLogContent(e.target.value)}
placeholder="Paste log output, error messages, or Event Viewer entries here..."
className="w-full rounded-lg border border-border bg-card px-4 py-3 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
rows={6}
/>
)}
{/* Submit */}
<div className="flex items-center justify-between">
<p className="text-[0.6875rem] text-[#5a6170]">
FlowPilot will analyze your input and guide you through diagnosis
</p>
<button
onClick={handleSubmit}
disabled={!hasContent}
className="rounded-lg bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 disabled:shadow-none transition-all"
>
Start Session
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { useState } from 'react'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { StepOptionSchema } from '@/types/ai-session'
interface FlowPilotOptionsProps {
options: StepOptionSchema[]
onSelect: (value: string) => void
disabled?: boolean
}
export function FlowPilotOptions({ options, onSelect, disabled }: FlowPilotOptionsProps) {
const [selected, setSelected] = useState<string | null>(null)
const handleSelect = (value: string) => {
if (disabled) return
setSelected(value)
onSelect(value)
}
return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{options.map((option) => {
const isSelected = selected === option.value
return (
<button
key={option.value}
onClick={() => handleSelect(option.value)}
disabled={disabled}
className={cn(
'group relative rounded-xl border p-4 text-left transition-all',
'hover:border-[rgba(6,182,212,0.3)] hover:shadow-[0_0_20px_rgba(6,182,212,0.08)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40',
isSelected
? 'border-primary/40 bg-primary/10'
: 'border-border bg-card/50',
disabled && 'pointer-events-none opacity-60'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<p className="text-sm font-medium text-foreground">{option.label}</p>
{option.followup_hint && (
<p className="mt-1 text-xs text-muted-foreground">{option.followup_hint}</p>
)}
</div>
{isSelected && (
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/20 text-primary">
<Check size={12} />
</span>
)}
</div>
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,171 @@
import { useEffect, useRef } from 'react'
import { Network, Clock, Hash } from 'lucide-react'
import type {
AISessionDetail,
AISessionStepResponse,
StepResponseRequest,
ResolveSessionRequest,
EscalateSessionRequest,
SessionDocumentation,
} from '@/types/ai-session'
import { ConfidenceIndicator } from './ConfidenceIndicator'
import { FlowPilotStepCard } from './FlowPilotStepCard'
import { FlowPilotActionBar } from './FlowPilotActionBar'
import { SessionDocView } from './SessionDocView'
interface FlowPilotSessionProps {
session: AISessionDetail
allSteps: AISessionStepResponse[]
currentStep: AISessionStepResponse | null
isProcessing: boolean
canResolve: boolean
canEscalate: boolean
documentation: SessionDocumentation | null
onRespond: (response: StepResponseRequest) => void
onResolve: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
onEscalate: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
onRate: (rating: number) => void
}
export function FlowPilotSession({
session,
allSteps,
currentStep,
isProcessing,
canResolve,
canEscalate,
documentation,
onRespond,
onResolve,
onEscalate,
onRate,
}: FlowPilotSessionProps) {
const scrollRef = useRef<HTMLDivElement>(null)
// Auto-scroll to latest step
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [allSteps.length])
const isCompleted = session.status === 'resolved' || session.status === 'escalated'
// Show documentation view for completed sessions
if (isCompleted && documentation) {
return (
<div className="flex h-full flex-col">
<div className="flex-1 overflow-y-auto p-6">
<SessionDocView
documentation={documentation}
onRate={onRate}
currentRating={session.session_rating}
/>
</div>
</div>
)
}
return (
<div className="flex h-full flex-col">
{/* Main content area: conversation + sidebar */}
<div className="flex flex-1 min-h-0">
{/* Conversation column */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl space-y-3">
{allSteps.map((step) => (
<FlowPilotStepCard
key={step.step_id}
step={step}
isCurrentStep={currentStep?.step_id === step.step_id}
isProcessing={isProcessing && currentStep?.step_id === step.step_id}
onRespond={onRespond}
/>
))}
</div>
</div>
{/* Sidebar */}
<div
className="hidden w-72 shrink-0 overflow-y-auto border-l p-4 lg:block"
style={{ borderColor: 'var(--glass-border)' }}
>
<div className="space-y-4">
{/* Problem summary */}
{session.problem_summary && (
<div>
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">
Problem
</h4>
<p className="text-sm text-foreground">{session.problem_summary}</p>
</div>
)}
{/* Domain */}
{session.problem_domain && (
<div>
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">
Domain
</h4>
<span className="font-label rounded-md bg-primary/10 px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-primary">
{session.problem_domain}
</span>
</div>
)}
{/* Confidence */}
<div>
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">
Confidence
</h4>
<ConfidenceIndicator
tier={session.confidence_tier}
score={currentStep?.confidence_score ?? 0}
/>
</div>
{/* Matched flow */}
{session.matched_flow_id && (
<div>
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">
Matched flow
</h4>
<div className="flex items-center gap-2">
<Network size={14} className="text-muted-foreground" />
<span className="text-xs text-foreground">
{session.match_score ? `${Math.round(session.match_score * 100)}% match` : 'Match found'}
</span>
</div>
</div>
)}
{/* Steps */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<Hash size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">{session.step_count} steps</span>
</div>
<div className="flex items-center gap-1.5">
<Clock size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{new Date(session.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Action bar */}
{session.status === 'active' && (
<FlowPilotActionBar
canResolve={canResolve}
canEscalate={canEscalate}
isProcessing={isProcessing}
onResolve={onResolve}
onEscalate={onEscalate}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,245 @@
import { useState } from 'react'
import { MessageSquare, Zap, CheckCircle2, SkipForward, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { AISessionStepResponse, StepResponseRequest } from '@/types/ai-session'
import { FlowPilotOptions } from './FlowPilotOptions'
interface FlowPilotStepCardProps {
step: AISessionStepResponse
isCurrentStep: boolean
isProcessing: boolean
onRespond: (response: StepResponseRequest) => void
}
const STEP_TYPE_ICONS = {
question: MessageSquare,
action: Zap,
intake_analysis: MessageSquare,
verification: CheckCircle2,
info_request: MessageSquare,
script_generation: Zap,
note: MessageSquare,
} as const
export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, onRespond }: FlowPilotStepCardProps) {
const [freeText, setFreeText] = useState('')
const [showFreeText, setShowFreeText] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(!isCurrentStep)
const content = step.content as Record<string, unknown>
const stepText = (content.text as string) || ''
const contentType = (content.type as string) || step.step_type
const isResolutionSuggestion = contentType === 'resolution_suggestion'
const Icon = STEP_TYPE_ICONS[step.step_type as keyof typeof STEP_TYPE_ICONS] ?? MessageSquare
const handleOptionSelect = (value: string) => {
onRespond({ selected_option: value })
}
const handleFreeTextSubmit = () => {
if (!freeText.trim()) return
onRespond({ free_text_input: freeText.trim() })
setFreeText('')
setShowFreeText(false)
}
const handleSkip = () => {
onRespond({ was_skipped: true })
}
const handleActionComplete = (success: boolean) => {
onRespond({
action_result: { success, details: '' },
})
}
const handleResolutionResponse = (accepted: boolean) => {
if (accepted) {
// Parent will handle opening the resolve modal
onRespond({ selected_option: 'resolution_accepted' })
} else {
onRespond({ free_text_input: 'No, keep investigating. The issue is not resolved.' })
}
}
// Completed step (collapsed view)
if (!isCurrentStep && isCollapsed) {
return (
<button
onClick={() => setIsCollapsed(false)}
className="w-full text-left glass-card-static p-4 opacity-70 hover:opacity-90 transition-opacity"
>
<div className="flex items-center gap-3">
<Icon size={16} className="shrink-0 text-muted-foreground" />
<p className="text-sm text-foreground truncate flex-1">{stepText}</p>
<ChevronDown size={14} className="shrink-0 text-muted-foreground" />
</div>
</button>
)
}
// Expanded completed step
if (!isCurrentStep && !isCollapsed) {
return (
<div className="glass-card-static p-4 opacity-80">
<button
onClick={() => setIsCollapsed(true)}
className="mb-2 flex w-full items-center justify-between text-left"
>
<div className="flex items-center gap-2">
<Icon size={16} className="text-muted-foreground" />
<span className="font-label text-[0.625rem] uppercase tracking-wider text-muted-foreground">
Step {step.step_order + 1}
</span>
</div>
<ChevronUp size={14} className="text-muted-foreground" />
</button>
<p className="text-sm text-foreground">{stepText}</p>
</div>
)
}
// Current active step
return (
<div
className={cn(
'glass-card-static p-5',
isResolutionSuggestion && 'border-emerald-500/30'
)}
>
{/* Context message */}
{step.context_message && (
<div className="mb-3 rounded-lg bg-primary/5 px-3 py-2 border border-primary/10">
<p className="text-xs text-muted-foreground">{step.context_message}</p>
</div>
)}
{/* Step content */}
<div className="flex items-start gap-3 mb-4">
<span className={cn(
'mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg',
isResolutionSuggestion ? 'bg-emerald-500/10 text-emerald-400' : 'bg-primary/10 text-primary'
)}>
<Icon size={14} />
</span>
<div>
<p className="text-sm font-medium text-foreground">{stepText}</p>
{isResolutionSuggestion && typeof content.resolution_summary === 'string' && (
<p className="mt-2 text-sm text-muted-foreground">
{content.resolution_summary}
</p>
)}
</div>
</div>
{/* Interactive area */}
{isCurrentStep && !isProcessing && (
<div className="space-y-3">
{/* Resolution suggestion buttons */}
{isResolutionSuggestion && (
<div className="flex gap-2">
<button
onClick={() => handleResolutionResponse(true)}
className="flex-1 rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-4 py-2.5 text-sm font-medium text-emerald-400 hover:bg-emerald-500/20 transition-colors"
>
Yes, this is resolved
</button>
<button
onClick={() => handleResolutionResponse(false)}
className="flex-1 rounded-lg bg-card/50 border border-border px-4 py-2.5 text-sm font-medium text-foreground hover:bg-card transition-colors"
>
No, keep investigating
</button>
</div>
)}
{/* Options for question steps */}
{!isResolutionSuggestion && step.options.length > 0 && (
<FlowPilotOptions
options={step.options}
onSelect={handleOptionSelect}
disabled={isProcessing}
/>
)}
{/* Action step buttons */}
{!isResolutionSuggestion && step.step_type === 'action' && (
<div className="flex gap-2">
<button
onClick={() => handleActionComplete(true)}
className="flex-1 rounded-lg bg-primary/10 border border-primary/20 px-4 py-2.5 text-sm font-medium text-primary hover:bg-primary/20 transition-colors"
>
I've completed this action
</button>
<button
onClick={() => handleActionComplete(false)}
className="flex-1 rounded-lg bg-card/50 border border-border px-4 py-2.5 text-sm font-medium text-foreground hover:bg-card transition-colors"
>
This didn't work
</button>
</div>
)}
{/* Free text escape hatch */}
{!isResolutionSuggestion && step.allow_free_text && (
<>
{!showFreeText ? (
<button
onClick={() => setShowFreeText(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
None of these let me describe
</button>
) : (
<div className="space-y-2">
<textarea
value={freeText}
onChange={(e) => setFreeText(e.target.value)}
placeholder="Describe what you're seeing..."
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
rows={3}
autoFocus
/>
<div className="flex gap-2">
<button
onClick={handleFreeTextSubmit}
disabled={!freeText.trim()}
className="rounded-lg bg-gradient-brand px-4 py-1.5 text-sm font-semibold text-[#101114] hover:opacity-90 disabled:opacity-50 transition-opacity"
>
Submit
</button>
<button
onClick={() => { setShowFreeText(false); setFreeText('') }}
className="rounded-lg px-4 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</>
)}
{/* Skip option */}
{!isResolutionSuggestion && step.allow_skip && (
<button
onClick={handleSkip}
className="flex items-center gap-1.5 text-xs text-[#5a6170] hover:text-muted-foreground transition-colors"
>
<SkipForward size={12} />
I can't check this right now
</button>
)}
</div>
)}
{/* Processing indicator */}
{isProcessing && (
<div className="flex items-center gap-2 pt-2">
<div className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
<span className="text-xs text-muted-foreground">FlowPilot is thinking...</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,120 @@
import { FileText, Clock, CheckCircle2, ArrowUpRight, Star } from 'lucide-react'
import type { SessionDocumentation } from '@/types/ai-session'
interface SessionDocViewProps {
documentation: SessionDocumentation
onRate?: (rating: number) => void
currentRating?: number | null
}
export function SessionDocView({ documentation, onRate, currentRating }: SessionDocViewProps) {
return (
<div className="space-y-5">
{/* Header */}
<div className="glass-card-static p-5">
<div className="flex items-start gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<FileText size={16} />
</span>
<div className="flex-1">
<h3 className="font-heading text-lg font-semibold text-foreground">Session Documentation</h3>
<p className="mt-1 text-sm text-muted-foreground">{documentation.problem_summary}</p>
<div className="mt-2 flex items-center gap-3 flex-wrap">
{documentation.problem_domain && (
<span className="font-label rounded-md bg-primary/10 px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-primary">
{documentation.problem_domain}
</span>
)}
{documentation.duration_display && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock size={12} />
{documentation.duration_display}
</span>
)}
<span className="text-xs text-muted-foreground">
{documentation.total_steps} steps
</span>
</div>
</div>
</div>
</div>
{/* Outcome */}
{(documentation.resolution_summary || documentation.escalation_reason) && (
<div className={`glass-card-static p-4 border-l-2 ${documentation.resolution_summary ? 'border-l-emerald-500' : 'border-l-amber-500'}`}>
<div className="flex items-center gap-2 mb-1">
{documentation.resolution_summary ? (
<CheckCircle2 size={14} className="text-emerald-400" />
) : (
<ArrowUpRight size={14} className="text-amber-400" />
)}
<span className="font-label text-[0.625rem] uppercase tracking-wider text-muted-foreground">
{documentation.resolution_summary ? 'Resolved' : 'Escalated'}
</span>
</div>
<p className="text-sm text-foreground">
{documentation.resolution_summary || documentation.escalation_reason}
</p>
</div>
)}
{/* Intake summary */}
<div className="glass-card-static p-4">
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-2">
Original intake
</h4>
<p className="text-sm text-foreground whitespace-pre-wrap">{documentation.intake_summary}</p>
</div>
{/* Diagnostic steps */}
<div className="space-y-2">
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-muted-foreground px-1">
Diagnostic trail
</h4>
{documentation.diagnostic_steps.map((step) => (
<div key={step.step_number} className="glass-card-static p-4">
<div className="flex items-start gap-3">
<span className="font-label flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-card text-[0.625rem] text-muted-foreground border border-border">
{step.step_number}
</span>
<div className="flex-1">
<p className="text-sm text-foreground">{step.description}</p>
{step.engineer_response && (
<p className="mt-1 text-xs text-primary"> {step.engineer_response}</p>
)}
{step.outcome && (
<p className="mt-1 text-xs text-muted-foreground">Outcome: {step.outcome}</p>
)}
</div>
</div>
</div>
))}
</div>
{/* Rating */}
{onRate && (
<div className="glass-card-static p-4 text-center">
<p className="text-sm text-muted-foreground mb-2">How helpful was this session?</p>
<div className="flex items-center justify-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => onRate(star)}
className="p-1 transition-colors"
>
<Star
size={20}
className={
(currentRating ?? 0) >= star
? 'fill-amber-400 text-amber-400'
: 'text-muted-foreground hover:text-amber-400'
}
/>
</button>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,8 @@
export { FlowPilotIntake } from './FlowPilotIntake'
export { FlowPilotSession } from './FlowPilotSession'
export { FlowPilotStepCard } from './FlowPilotStepCard'
export { FlowPilotOptions } from './FlowPilotOptions'
export { FlowPilotActionBar } from './FlowPilotActionBar'
export { ConfidenceIndicator } from './ConfidenceIndicator'
export { SessionDocView } from './SessionDocView'
export { AISessionListItem } from './AISessionListItem'

View File

@@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom'
import {
LayoutGrid, Network, Wrench, Clock, FileOutput, BarChart3,
Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText,
BookOpen, Lightbulb, Code2, Library, Brain, WandSparkles,
BookOpen, Lightbulb, Code2, Library, Brain, WandSparkles, Sparkles,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
@@ -83,6 +83,7 @@ export function Sidebar() {
<>
{/* Collapsed: icon-only nav */}
<div className="flex flex-col items-center px-1.5 py-3 space-y-1">
<NavItem href="/pilot" icon={Sparkles} label="New Session" iconColor={NAV_COLORS.dashboard} collapsed />
<NavItem href="/" icon={LayoutGrid} label="Dashboard" iconColor={NAV_COLORS.dashboard} collapsed />
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={stats?.active_count || undefined} iconColor={NAV_COLORS.sessions} collapsed />
<NavItem href="/trees" icon={Network} label="All Flows" matchPaths={['/trees', '/flows']} iconColor={NAV_COLORS.flows} collapsed />
@@ -117,6 +118,13 @@ export function Sidebar() {
<div style={{ borderBottom: '1px solid var(--glass-border)' }} />
{/* New Session CTA */}
<div className="px-3 pt-2 pb-1">
<NavItem href="/pilot" icon={Sparkles} label="New Session" iconColor={NAV_COLORS.dashboard} />
</div>
<div style={{ borderBottom: '1px solid var(--glass-border)' }} />
{/* Navigation */}
<div className="px-3 py-2 space-y-0.5">
{/* Dashboard (standalone) */}

View File

@@ -0,0 +1,209 @@
import { useState, useCallback } from 'react'
import { aiSessionsApi } from '@/api'
import type {
AISessionCreateRequest,
AISessionCreateResponse,
AISessionDetail,
AISessionStepResponse,
StepResponseRequest,
StepResponseResponse,
ResolveSessionRequest,
EscalateSessionRequest,
SessionDocumentation,
} from '@/types/ai-session'
import { toast } from '@/lib/toast'
export interface UseFlowPilotSession {
// State
session: AISessionDetail | null
currentStep: AISessionStepResponse | null
allSteps: AISessionStepResponse[]
isLoading: boolean
isProcessing: boolean
error: string | null
// Actions
startSession: (intake: AISessionCreateRequest) => Promise<void>
respondToStep: (response: StepResponseRequest) => Promise<void>
resolveSession: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
escalateSession: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
rateSession: (rating: number, feedback?: string) => Promise<void>
loadSession: (sessionId: string) => Promise<void>
// Derived
isActive: boolean
canResolve: boolean
canEscalate: boolean
// Post-close
documentation: SessionDocumentation | null
}
export function useFlowPilotSession(): UseFlowPilotSession {
const [session, setSession] = useState<AISessionDetail | null>(null)
const [currentStep, setCurrentStep] = useState<AISessionStepResponse | null>(null)
const [allSteps, setAllSteps] = useState<AISessionStepResponse[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [documentation, setDocumentation] = useState<SessionDocumentation | null>(null)
const startSession = useCallback(async (intake: AISessionCreateRequest) => {
setIsLoading(true)
setError(null)
try {
const result: AISessionCreateResponse = await aiSessionsApi.createSession(intake)
const firstStep = result.first_step
setSession({
id: result.session_id,
status: result.status,
intake_type: intake.intake_type,
intake_content: intake.intake_content,
problem_summary: result.problem_summary,
problem_domain: result.problem_domain,
confidence_tier: result.confidence_tier,
step_count: 1,
session_rating: null,
created_at: new Date().toISOString(),
resolved_at: null,
matched_flow_id: result.matched_flow_id,
match_score: result.match_score,
resolution_summary: null,
resolution_action: null,
escalation_reason: null,
session_feedback: null,
steps: [firstStep],
})
setAllSteps([firstStep])
setCurrentStep(firstStep)
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Failed to start session'
setError(message)
toast.error(message)
} finally {
setIsLoading(false)
}
}, [])
const respondToStep = useCallback(async (response: StepResponseRequest) => {
if (!session) return
setIsProcessing(true)
setError(null)
try {
const result: StepResponseResponse = await aiSessionsApi.respondToStep(session.id, response)
setSession(prev => prev ? {
...prev,
status: result.status,
confidence_tier: result.confidence_tier,
step_count: prev.step_count + 1,
} : null)
if (result.next_step) {
setAllSteps(prev => [...prev, result.next_step!])
setCurrentStep(result.next_step)
}
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Failed to process response'
setError(message)
toast.error(message)
} finally {
setIsProcessing(false)
}
}, [session])
const resolveSession = useCallback(async (data: ResolveSessionRequest): Promise<SessionDocumentation> => {
if (!session) throw new Error('No active session')
setIsProcessing(true)
try {
const result = await aiSessionsApi.resolveSession(session.id, data)
setSession(prev => prev ? { ...prev, status: 'resolved' } : null)
setDocumentation(result.documentation)
setCurrentStep(null)
toast.success('Session resolved')
return result.documentation
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Failed to resolve session'
toast.error(message)
throw e
} finally {
setIsProcessing(false)
}
}, [session])
const escalateSession = useCallback(async (data: EscalateSessionRequest): Promise<SessionDocumentation> => {
if (!session) throw new Error('No active session')
setIsProcessing(true)
try {
const result = await aiSessionsApi.escalateSession(session.id, data)
setSession(prev => prev ? { ...prev, status: 'escalated' } : null)
setDocumentation(result.documentation)
setCurrentStep(null)
toast.success('Session escalated')
return result.documentation
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Failed to escalate session'
toast.error(message)
throw e
} finally {
setIsProcessing(false)
}
}, [session])
const rateSession = useCallback(async (rating: number, feedback?: string) => {
if (!session) return
try {
await aiSessionsApi.rateSession(session.id, { rating, feedback })
setSession(prev => prev ? { ...prev, session_rating: rating } : null)
toast.success('Thanks for your feedback!')
} catch {
toast.error('Failed to submit rating')
}
}, [session])
const loadSession = useCallback(async (sessionId: string) => {
setIsLoading(true)
setError(null)
try {
const detail = await aiSessionsApi.getSession(sessionId)
setSession(detail)
setAllSteps(detail.steps)
setCurrentStep(detail.status === 'active' ? detail.steps[detail.steps.length - 1] ?? null : null)
if (detail.status === 'resolved' || detail.status === 'escalated') {
const doc = await aiSessionsApi.getDocumentation(sessionId)
setDocumentation(doc)
}
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Failed to load session'
setError(message)
toast.error(message)
} finally {
setIsLoading(false)
}
}, [])
const isActive = session?.status === 'active'
const canResolve = isActive && allSteps.length >= 1
const canEscalate = isActive && allSteps.length >= 1
return {
session,
currentStep,
allSteps,
isLoading,
isProcessing,
error,
startSession,
respondToStep,
resolveSession,
escalateSession,
rateSession,
loadSession,
isActive,
canResolve,
canEscalate,
documentation,
}
}

View File

@@ -0,0 +1,83 @@
import { useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { Sparkles } from 'lucide-react'
import { useFlowPilotSession } from '@/hooks/useFlowPilotSession'
import { FlowPilotIntake, FlowPilotSession } from '@/components/flowpilot'
export default function FlowPilotSessionPage() {
const { sessionId } = useParams<{ sessionId?: string }>()
const fp = useFlowPilotSession()
// Load existing session if ID in URL
useEffect(() => {
if (sessionId && !fp.session) {
fp.loadSession(sessionId)
}
}, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
// Error state
if (fp.error && !fp.session) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="glass-card-static p-6 text-center max-w-md">
<p className="text-sm text-rose-400">{fp.error}</p>
<button
onClick={() => window.location.reload()}
className="mt-3 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Try again
</button>
</div>
</div>
)
}
// Intake screen (no session yet)
if (!fp.session) {
return (
<div className="h-full p-6">
<FlowPilotIntake onSubmit={fp.startSession} isLoading={fp.isLoading} />
</div>
)
}
// Active/completed session
return (
<div className="flex h-full flex-col">
{/* Header */}
<div
className="flex items-center gap-3 border-b px-5 py-3 shrink-0"
style={{ borderColor: 'var(--glass-border)' }}
>
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10">
<Sparkles size={14} className="text-primary" />
</span>
<div className="flex-1 min-w-0">
<h1 className="font-heading text-sm font-semibold text-foreground truncate">
{fp.session.problem_summary || 'FlowPilot Session'}
</h1>
</div>
<span className="font-label rounded-md bg-card px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-muted-foreground border border-border">
{fp.session.status}
</span>
</div>
{/* Session content */}
<div className="flex-1 min-h-0">
<FlowPilotSession
session={fp.session}
allSteps={fp.allSteps}
currentStep={fp.currentStep}
isProcessing={fp.isProcessing}
canResolve={fp.canResolve}
canEscalate={fp.canEscalate}
documentation={fp.documentation}
onRespond={fp.respondToStep}
onResolve={fp.resolveSession}
onEscalate={fp.escalateSession}
onRate={fp.rateSession}
/>
</div>
</div>
)
}

View File

@@ -2,14 +2,16 @@ import { useEffect, useState, useRef, useCallback } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
import { sessionsApi } from '@/api/sessions'
import { aiSessionsApi } from '@/api/aiSessions'
import { treesApi } from '@/api/trees'
import type { Session, TreeListItem, SessionOutcome } from '@/types'
import type { Session, TreeListItem, SessionOutcome, AISessionSummary } from '@/types'
import type { DateRange } from 'react-day-picker'
import { SessionFilters } from '@/components/session/SessionFilters'
import type { SessionFilterState } from '@/components/session/SessionFilters'
import { Spinner } from '@/components/common/Spinner'
import { EmptyState } from '@/components/common/EmptyState'
import { SessionIllustration } from '@/components/common/EmptyStateIllustrations'
import { AISessionListItem } from '@/components/flowpilot/AISessionListItem'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { getSessionResumePath } from '@/lib/routing'
@@ -18,6 +20,11 @@ export function SessionHistoryPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
// Top-level tab: flow sessions vs AI sessions
const [sessionType, setSessionType] = useState<'flow' | 'ai'>('flow')
const [aiSessions, setAiSessions] = useState<AISessionSummary[]>([])
const [aiLoading, setAiLoading] = useState(false)
const [sessions, setSessions] = useState<Session[]>([])
const [hasMore, setHasMore] = useState(false)
const [trees, setTrees] = useState<TreeListItem[]>([])
@@ -140,6 +147,25 @@ export function SessionHistoryPage() {
setSearchParams(params, { replace: true })
}, [filters, setSearchParams])
// Load AI sessions when tab is active
useEffect(() => {
if (sessionType !== 'ai') return
let cancelled = false
const loadAiSessions = async () => {
setAiLoading(true)
try {
const data = await aiSessionsApi.listSessions({ limit: 50 })
if (!cancelled) setAiSessions(data)
} catch {
if (!cancelled) toast.error('Failed to load AI sessions')
} finally {
if (!cancelled) setAiLoading(false)
}
}
loadAiSessions()
return () => { cancelled = true }
}, [sessionType])
const handleFilterChange = (newFilters: SessionFilterState) => {
setFilters(newFilters)
}
@@ -226,7 +252,34 @@ export function SessionHistoryPage() {
</p>
</div>
{/* Filter Tabs */}
{/* Session type toggle */}
<div className="mb-4 flex gap-1 rounded-lg bg-card/50 p-1 w-fit border border-border">
<button
onClick={() => setSessionType('flow')}
className={cn(
'rounded-md px-4 py-1.5 text-sm font-medium transition-colors',
sessionType === 'flow'
? 'bg-primary/10 text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
Flow Sessions
</button>
<button
onClick={() => setSessionType('ai')}
className={cn(
'rounded-md px-4 py-1.5 text-sm font-medium transition-colors',
sessionType === 'ai'
? 'bg-primary/10 text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
AI Sessions
</button>
</div>
{/* Filter Tabs (flow sessions only) */}
{sessionType === 'flow' && (
<div className="mb-6 flex gap-2 border-b border-border">
{(['active', 'prepared', 'completed', 'all'] as const).map((tab) => (
<button
@@ -243,8 +296,39 @@ export function SessionHistoryPage() {
</button>
))}
</div>
)}
{/* Search and Filter Controls */}
{/* AI Sessions view */}
{sessionType === 'ai' && (
aiLoading ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
) : aiSessions.length === 0 ? (
<EmptyState
title="No AI sessions yet"
description="Start a FlowPilot session to get AI-guided troubleshooting. Sessions will appear here."
action={
<Link
to="/pilot"
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
>
Start AI Session
</Link>
}
/>
) : (
<div className="space-y-2">
{aiSessions.map((s) => (
<AISessionListItem key={s.id} session={s} />
))}
</div>
)
)}
{/* Flow Sessions Content */}
{sessionType === 'flow' && (
<>
<div className="mb-6">
<SessionFilters
filters={filters}
@@ -474,6 +558,8 @@ export function SessionHistoryPage() {
) : null}
</>
)}
</>
)}
</div>
</div>
)

View File

@@ -45,6 +45,7 @@ const ScriptLibraryPage = lazy(() => import('@/pages/ScriptLibraryPage'))
const ScriptManagePage = lazy(() => import('@/pages/ScriptManagePage'))
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
const FlowAssistPage = lazy(() => import('@/pages/FlowAssistPage'))
const FlowPilotSessionPage = lazy(() => import('@/pages/FlowPilotSessionPage'))
const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage'))
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
@@ -169,6 +170,8 @@ export const router = sentryCreateBrowserRouter([
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
{ path: 'assistant', element: page(AssistantChatPage) },
{ path: 'flow-assist', element: page(FlowAssistPage) },
{ path: 'pilot', element: page(FlowPilotSessionPage) },
{ path: 'pilot/:sessionId', element: page(FlowPilotSessionPage) },
{ path: 'guides', element: page(GuidesHubPage) },
{ path: 'guides/:slug', element: page(GuideDetailPage) },
// Admin routes

View File

@@ -0,0 +1,129 @@
// ── Intake ──
export interface AISessionCreateRequest {
intake_type: 'free_text' | 'psa_ticket' | 'screenshot' | 'log_paste' | 'combined'
intake_content: Record<string, unknown>
psa_ticket_id?: string
psa_connection_id?: string
}
export interface AISessionCreateResponse {
session_id: string
status: string
confidence_tier: string
problem_summary: string | null
problem_domain: string | null
matched_flow_id: string | null
matched_flow_name: string | null
match_score: number | null
first_step: AISessionStepResponse
}
// ── Step interaction ──
export interface StepOptionSchema {
label: string
value: string
followup_hint: string | null
}
export interface AISessionStepResponse {
step_id: string
step_order: number
step_type: string
content: Record<string, unknown>
context_message: string | null
options: StepOptionSchema[]
allow_free_text: boolean
allow_skip: boolean
confidence_tier: string
confidence_score: number
}
export interface StepResponseRequest {
selected_option?: string | null
free_text_input?: string | null
was_skipped?: boolean
action_result?: Record<string, unknown> | null
}
export interface StepResponseResponse {
session_id: string
status: string
confidence_tier: string
confidence_score: number
next_step: AISessionStepResponse | null
resolution_suggested: boolean
resolution_summary: string | null
}
// ── Resolution / Escalation ──
export interface ResolveSessionRequest {
resolution_summary: string
resolution_action?: string | null
session_rating?: number | null
session_feedback?: string | null
}
export interface EscalateSessionRequest {
escalation_reason: string
escalated_to_id?: string | null
}
export interface DocumentationStep {
step_number: number
step_type: string
description: string
engineer_response: string | null
outcome: string | null
}
export interface SessionDocumentation {
problem_summary: string
problem_domain: string | null
intake_summary: string
diagnostic_steps: DocumentationStep[]
resolution_summary: string | null
escalation_reason: string | null
total_steps: number
duration_display: string | null
generated_at: string
}
export interface SessionCloseResponse {
session_id: string
status: string
documentation: SessionDocumentation
}
export interface RateSessionRequest {
rating: number
feedback?: string | null
}
// ── List / Detail ──
export interface AISessionSummary {
id: string
status: string
intake_type: string
problem_summary: string | null
problem_domain: string | null
confidence_tier: string
step_count: number
session_rating: number | null
created_at: string
resolved_at: string | null
}
export interface AISessionDetail extends AISessionSummary {
intake_content: Record<string, unknown>
matched_flow_id: string | null
match_score: number | null
resolution_summary: string | null
resolution_action: string | null
escalation_reason: string | null
session_feedback: string | null
steps: AISessionStepResponse[]
}

View File

@@ -12,6 +12,7 @@ export * from './admin'
export * from './analytics'
export * from './copilot'
export type { AssistantChat, AssistantChatMessage, ChatListItem, ChatMessageResponse, RetentionSettings } from './assistant-chat'
export * from './ai-session'
// API response wrapper types
export interface PaginatedResponse<T> {