feat: unified sessions — merge assistant chat into ai_sessions table
Add session_type ('guided'|'chat') and title columns to ai_sessions,
enabling both FlowPilot guided sessions and assistant chat sessions to
live in a single table. This is the foundation for a unified session
history and consistent UX across both interaction modes.
Backend:
- Migration 066: session_type + title columns
- unified_chat_service: chat sessions on ai_sessions with same AI/RAG
- POST /ai-sessions supports session_type='chat' creation
- POST /ai-sessions/{id}/chat for chat messages
- DELETE /ai-sessions/{id} for session deletion
- session_type filter on GET /ai-sessions
Frontend:
- AssistantChatPage rewired to aiSessionsApi (no more assistantChatApi)
- /assistant/:sessionId route for deep-linking
- Session history: type filter pills (All/Guided/Chat), type icons
- Dashboard: both types shown with correct routing and icons
- Fixed glass-border → border-default in dashboard components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,8 +45,12 @@ from app.schemas.ai_session import (
|
||||
AISessionStepResponse,
|
||||
AISessionSearchResult,
|
||||
StepOptionSchema,
|
||||
ChatSessionCreateResponse,
|
||||
ChatMessageRequest,
|
||||
ChatMessageResponse,
|
||||
)
|
||||
from app.services import flowpilot_engine
|
||||
from app.services import unified_chat_service
|
||||
from app.services.psa_documentation_service import retry_failed_push
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -89,6 +93,8 @@ def _build_session_detail(session: AISession) -> AISessionDetail:
|
||||
|
||||
return AISessionDetail(
|
||||
id=session.id,
|
||||
session_type=getattr(session, 'session_type', 'guided'),
|
||||
title=getattr(session, 'title', None),
|
||||
status=session.status,
|
||||
intake_type=session.intake_type,
|
||||
intake_content=session.intake_content or {},
|
||||
@@ -109,6 +115,7 @@ def _build_session_detail(session: AISession) -> AISessionDetail:
|
||||
created_at=session.created_at,
|
||||
resolved_at=session.resolved_at,
|
||||
steps=step_responses,
|
||||
conversation_messages=session.conversation_messages or [],
|
||||
)
|
||||
|
||||
|
||||
@@ -176,7 +183,7 @@ async def _record_usage(
|
||||
|
||||
# ── Create session ──
|
||||
|
||||
@router.post("", response_model=AISessionCreateResponse, status_code=201)
|
||||
@router.post("", status_code=201)
|
||||
@limiter.limit("5/minute")
|
||||
async def create_session(
|
||||
request: Request,
|
||||
@@ -185,10 +192,35 @@ async def create_session(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Start a new FlowPilot troubleshooting session."""
|
||||
"""Start a new FlowPilot or chat session."""
|
||||
_require_ai_enabled()
|
||||
await _check_quota(current_user, db)
|
||||
|
||||
# Chat sessions use a different creation path
|
||||
if data.session_type == "chat":
|
||||
try:
|
||||
session = await unified_chat_service.create_chat_session(
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
team_id=current_user.team_id,
|
||||
intake_content=data.intake_content,
|
||||
db=db,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Chat session creation failed: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create chat session",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return ChatSessionCreateResponse(
|
||||
session_id=session.id,
|
||||
title=session.title or "New Chat",
|
||||
status=session.status,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await flowpilot_engine.start_session(
|
||||
request=data,
|
||||
@@ -229,6 +261,70 @@ async def create_session(
|
||||
return result
|
||||
|
||||
|
||||
# ── Chat message ──
|
||||
|
||||
@router.post("/{session_id}/chat", response_model=ChatMessageResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def send_chat_message(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
data: ChatMessageRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Send a message in a chat session and get AI response."""
|
||||
_require_ai_enabled()
|
||||
await _check_quota(current_user, db)
|
||||
|
||||
user_id = current_user.id
|
||||
account_id = current_user.account_id
|
||||
|
||||
try:
|
||||
ai_content, suggested_flows, session = await unified_chat_service.send_chat_message(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
message=data.message,
|
||||
db=db,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception("Chat message failed: %s", e)
|
||||
await db.rollback()
|
||||
try:
|
||||
await _record_usage(
|
||||
current_user, db,
|
||||
generation_type="chat_message",
|
||||
input_tokens=0, output_tokens=0,
|
||||
succeeded=False,
|
||||
session_id=session_id,
|
||||
error_code=type(e).__name__,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.warning("Failed to record usage after chat failure", exc_info=True)
|
||||
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="chat_message",
|
||||
input_tokens=0, output_tokens=0,
|
||||
succeeded=True,
|
||||
session_id=session_id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return ChatMessageResponse(
|
||||
content=ai_content,
|
||||
suggested_flows=suggested_flows,
|
||||
)
|
||||
|
||||
|
||||
# ── Respond to step ──
|
||||
|
||||
@router.post("/{session_id}/respond", response_model=StepResponseResponse)
|
||||
@@ -426,6 +522,29 @@ async def abandon_session(
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Delete ──
|
||||
|
||||
@router.delete("/{session_id}", status_code=204)
|
||||
async def delete_session(
|
||||
session_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Delete a session (owner only)."""
|
||||
result = await db.execute(
|
||||
select(AISession).where(
|
||||
AISession.id == session_id,
|
||||
AISession.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||
|
||||
await db.delete(session)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Escalation Queue ──
|
||||
|
||||
@router.get("/escalation-queue", response_model=list[AISessionSummary])
|
||||
@@ -638,6 +757,7 @@ async def list_sessions(
|
||||
matched_flow_id: Optional[UUID] = Query(None),
|
||||
confidence_tier: Optional[str] = Query(None, pattern="^(guided|exploring|discovery)$"),
|
||||
ticket_id: Optional[str] = Query(None),
|
||||
session_type: Optional[str] = Query(None, pattern="^(guided|chat)$"),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
q: Optional[str] = Query(None, min_length=2, max_length=200),
|
||||
@@ -657,6 +777,8 @@ async def list_sessions(
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if session_type:
|
||||
query = query.where(AISession.session_type == session_type)
|
||||
if session_status:
|
||||
query = query.where(AISession.status == session_status)
|
||||
if problem_domain:
|
||||
|
||||
Reference in New Issue
Block a user