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:
@@ -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.kb_import import KBImport, KBImportNode # noqa: F401
|
||||||
from app.models.script_template import ScriptCategory, ScriptTemplate, ScriptGeneration # 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.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_post_log import PsaPostLog # noqa: F401
|
||||||
from app.models.psa_member_mapping import PsaMemberMapping # noqa: F401
|
from app.models.psa_member_mapping import PsaMemberMapping # noqa: F401
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|||||||
@@ -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")
|
||||||
411
backend/app/api/endpoints/ai_sessions.py
Normal file
411
backend/app/api/endpoints/ai_sessions.py
Normal 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()
|
||||||
@@ -21,6 +21,7 @@ from app.api.endpoints import integrations
|
|||||||
from app.api.endpoints import onboarding
|
from app.api.endpoints import onboarding
|
||||||
from app.api.endpoints import branding
|
from app.api.endpoints import branding
|
||||||
from app.api.endpoints import supporting_data
|
from app.api.endpoints import supporting_data
|
||||||
|
from app.api.endpoints import ai_sessions
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -67,3 +68,4 @@ api_router.include_router(integrations.router)
|
|||||||
api_router.include_router(onboarding.router)
|
api_router.include_router(onboarding.router)
|
||||||
api_router.include_router(branding.router)
|
api_router.include_router(branding.router)
|
||||||
api_router.include_router(supporting_data.router)
|
api_router.include_router(supporting_data.router)
|
||||||
|
api_router.include_router(ai_sessions.router)
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ from .survey_response import SurveyResponse
|
|||||||
from .survey_invite import SurveyInvite
|
from .survey_invite import SurveyInvite
|
||||||
from .kb_import import KBImport, KBImportNode
|
from .kb_import import KBImport, KBImportNode
|
||||||
from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration
|
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_connection import PsaConnection
|
||||||
from .psa_post_log import PsaPostLog
|
from .psa_post_log import PsaPostLog
|
||||||
from .psa_member_mapping import PsaMemberMapping
|
from .psa_member_mapping import PsaMemberMapping
|
||||||
@@ -90,6 +92,8 @@ __all__ = [
|
|||||||
"ScriptCategory",
|
"ScriptCategory",
|
||||||
"ScriptTemplate",
|
"ScriptTemplate",
|
||||||
"ScriptGeneration",
|
"ScriptGeneration",
|
||||||
|
"AISession",
|
||||||
|
"AISessionStep",
|
||||||
"PsaConnection",
|
"PsaConnection",
|
||||||
"PsaPostLog",
|
"PsaPostLog",
|
||||||
"PsaMemberMapping",
|
"PsaMemberMapping",
|
||||||
|
|||||||
204
backend/app/models/ai_session.py
Normal file
204
backend/app/models/ai_session.py
Normal 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",
|
||||||
|
)
|
||||||
133
backend/app/models/ai_session_step.py
Normal file
133
backend/app/models/ai_session_step.py
Normal 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")
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, Any, TYPE_CHECKING
|
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.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -161,6 +161,25 @@ class Tree(Base):
|
|||||||
comment="Provenance metadata from .rfflow file import"
|
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
|
# Relationships
|
||||||
author: Mapped[Optional["User"]] = relationship("User", foreign_keys=[author_id], back_populates="trees")
|
author: Mapped[Optional["User"]] = relationship("User", foreign_keys=[author_id], back_populates="trees")
|
||||||
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees")
|
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees")
|
||||||
|
|||||||
171
backend/app/schemas/ai_session.py
Normal file
171
backend/app/schemas/ai_session.py
Normal 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}
|
||||||
278
backend/app/services/flow_matching_engine.py
Normal file
278
backend/app/services/flow_matching_engine.py
Normal 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
|
||||||
737
backend/app/services/flowpilot_engine.py
Normal file
737
backend/app/services/flowpilot_engine.py
Normal 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,
|
||||||
|
}
|
||||||
67
frontend/src/api/aiSessions.ts
Normal file
67
frontend/src/api/aiSessions.ts
Normal 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
|
||||||
@@ -24,3 +24,4 @@ export { scriptsApi } from './scripts'
|
|||||||
export { integrationsApi, sessionPsaApi } from './integrations'
|
export { integrationsApi, sessionPsaApi } from './integrations'
|
||||||
export { sidebarApi } from './sidebar'
|
export { sidebarApi } from './sidebar'
|
||||||
export { sessionToFlowApi } from './sessionToFlow'
|
export { sessionToFlowApi } from './sessionToFlow'
|
||||||
|
export { aiSessionsApi } from './aiSessions'
|
||||||
|
|||||||
60
frontend/src/components/flowpilot/AISessionListItem.tsx
Normal file
60
frontend/src/components/flowpilot/AISessionListItem.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
frontend/src/components/flowpilot/ConfidenceIndicator.tsx
Normal file
44
frontend/src/components/flowpilot/ConfidenceIndicator.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
frontend/src/components/flowpilot/FlowPilotActionBar.tsx
Normal file
140
frontend/src/components/flowpilot/FlowPilotActionBar.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
frontend/src/components/flowpilot/FlowPilotIntake.tsx
Normal file
119
frontend/src/components/flowpilot/FlowPilotIntake.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
frontend/src/components/flowpilot/FlowPilotOptions.tsx
Normal file
58
frontend/src/components/flowpilot/FlowPilotOptions.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
171
frontend/src/components/flowpilot/FlowPilotSession.tsx
Normal file
171
frontend/src/components/flowpilot/FlowPilotSession.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
245
frontend/src/components/flowpilot/FlowPilotStepCard.tsx
Normal file
245
frontend/src/components/flowpilot/FlowPilotStepCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
frontend/src/components/flowpilot/SessionDocView.tsx
Normal file
120
frontend/src/components/flowpilot/SessionDocView.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
frontend/src/components/flowpilot/index.ts
Normal file
8
frontend/src/components/flowpilot/index.ts
Normal 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'
|
||||||
@@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom'
|
|||||||
import {
|
import {
|
||||||
LayoutGrid, Network, Wrench, Clock, FileOutput, BarChart3,
|
LayoutGrid, Network, Wrench, Clock, FileOutput, BarChart3,
|
||||||
Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText,
|
Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText,
|
||||||
BookOpen, Lightbulb, Code2, Library, Brain, WandSparkles,
|
BookOpen, Lightbulb, Code2, Library, Brain, WandSparkles, Sparkles,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
@@ -83,6 +83,7 @@ export function Sidebar() {
|
|||||||
<>
|
<>
|
||||||
{/* Collapsed: icon-only nav */}
|
{/* Collapsed: icon-only nav */}
|
||||||
<div className="flex flex-col items-center px-1.5 py-3 space-y-1">
|
<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="/" 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="/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 />
|
<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)' }} />
|
<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 */}
|
{/* Navigation */}
|
||||||
<div className="px-3 py-2 space-y-0.5">
|
<div className="px-3 py-2 space-y-0.5">
|
||||||
{/* Dashboard (standalone) */}
|
{/* Dashboard (standalone) */}
|
||||||
|
|||||||
209
frontend/src/hooks/useFlowPilotSession.ts
Normal file
209
frontend/src/hooks/useFlowPilotSession.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
83
frontend/src/pages/FlowPilotSessionPage.tsx
Normal file
83
frontend/src/pages/FlowPilotSessionPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,14 +2,16 @@ import { useEffect, useState, useRef, useCallback } from 'react'
|
|||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { PageMeta } from '@/components/common/PageMeta'
|
import { PageMeta } from '@/components/common/PageMeta'
|
||||||
import { sessionsApi } from '@/api/sessions'
|
import { sessionsApi } from '@/api/sessions'
|
||||||
|
import { aiSessionsApi } from '@/api/aiSessions'
|
||||||
import { treesApi } from '@/api/trees'
|
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 type { DateRange } from 'react-day-picker'
|
||||||
import { SessionFilters } from '@/components/session/SessionFilters'
|
import { SessionFilters } from '@/components/session/SessionFilters'
|
||||||
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
||||||
import { Spinner } from '@/components/common/Spinner'
|
import { Spinner } from '@/components/common/Spinner'
|
||||||
import { EmptyState } from '@/components/common/EmptyState'
|
import { EmptyState } from '@/components/common/EmptyState'
|
||||||
import { SessionIllustration } from '@/components/common/EmptyStateIllustrations'
|
import { SessionIllustration } from '@/components/common/EmptyStateIllustrations'
|
||||||
|
import { AISessionListItem } from '@/components/flowpilot/AISessionListItem'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { getSessionResumePath } from '@/lib/routing'
|
import { getSessionResumePath } from '@/lib/routing'
|
||||||
@@ -18,6 +20,11 @@ export function SessionHistoryPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
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 [sessions, setSessions] = useState<Session[]>([])
|
||||||
const [hasMore, setHasMore] = useState(false)
|
const [hasMore, setHasMore] = useState(false)
|
||||||
const [trees, setTrees] = useState<TreeListItem[]>([])
|
const [trees, setTrees] = useState<TreeListItem[]>([])
|
||||||
@@ -140,6 +147,25 @@ export function SessionHistoryPage() {
|
|||||||
setSearchParams(params, { replace: true })
|
setSearchParams(params, { replace: true })
|
||||||
}, [filters, setSearchParams])
|
}, [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) => {
|
const handleFilterChange = (newFilters: SessionFilterState) => {
|
||||||
setFilters(newFilters)
|
setFilters(newFilters)
|
||||||
}
|
}
|
||||||
@@ -226,7 +252,34 @@ export function SessionHistoryPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="mb-6 flex gap-2 border-b border-border">
|
||||||
{(['active', 'prepared', 'completed', 'all'] as const).map((tab) => (
|
{(['active', 'prepared', 'completed', 'all'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
@@ -243,8 +296,39 @@ export function SessionHistoryPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="mb-6">
|
||||||
<SessionFilters
|
<SessionFilters
|
||||||
filters={filters}
|
filters={filters}
|
||||||
@@ -474,6 +558,8 @@ export function SessionHistoryPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const ScriptLibraryPage = lazy(() => import('@/pages/ScriptLibraryPage'))
|
|||||||
const ScriptManagePage = lazy(() => import('@/pages/ScriptManagePage'))
|
const ScriptManagePage = lazy(() => import('@/pages/ScriptManagePage'))
|
||||||
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
|
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
|
||||||
const FlowAssistPage = lazy(() => import('@/pages/FlowAssistPage'))
|
const FlowAssistPage = lazy(() => import('@/pages/FlowAssistPage'))
|
||||||
|
const FlowPilotSessionPage = lazy(() => import('@/pages/FlowPilotSessionPage'))
|
||||||
const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage'))
|
const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage'))
|
||||||
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
|
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
|
||||||
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
|
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
|
||||||
@@ -169,6 +170,8 @@ export const router = sentryCreateBrowserRouter([
|
|||||||
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
|
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
|
||||||
{ path: 'assistant', element: page(AssistantChatPage) },
|
{ path: 'assistant', element: page(AssistantChatPage) },
|
||||||
{ path: 'flow-assist', element: page(FlowAssistPage) },
|
{ path: 'flow-assist', element: page(FlowAssistPage) },
|
||||||
|
{ path: 'pilot', element: page(FlowPilotSessionPage) },
|
||||||
|
{ path: 'pilot/:sessionId', element: page(FlowPilotSessionPage) },
|
||||||
{ path: 'guides', element: page(GuidesHubPage) },
|
{ path: 'guides', element: page(GuidesHubPage) },
|
||||||
{ path: 'guides/:slug', element: page(GuideDetailPage) },
|
{ path: 'guides/:slug', element: page(GuideDetailPage) },
|
||||||
// Admin routes
|
// Admin routes
|
||||||
|
|||||||
129
frontend/src/types/ai-session.ts
Normal file
129
frontend/src/types/ai-session.ts
Normal 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[]
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export * from './admin'
|
|||||||
export * from './analytics'
|
export * from './analytics'
|
||||||
export * from './copilot'
|
export * from './copilot'
|
||||||
export type { AssistantChat, AssistantChatMessage, ChatListItem, ChatMessageResponse, RetentionSettings } from './assistant-chat'
|
export type { AssistantChat, AssistantChatMessage, ChatListItem, ChatMessageResponse, RetentionSettings } from './assistant-chat'
|
||||||
|
export * from './ai-session'
|
||||||
|
|
||||||
// API response wrapper types
|
// API response wrapper types
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|||||||
Reference in New Issue
Block a user