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:
|
||||
|
||||
@@ -66,6 +66,16 @@ class AISession(Base):
|
||||
index=True,
|
||||
)
|
||||
|
||||
# ── Session type ──
|
||||
session_type: Mapped[str] = mapped_column(
|
||||
String(10), nullable=False, default="guided", index=True,
|
||||
comment="Session type: guided (FlowPilot) or chat (assistant)",
|
||||
)
|
||||
title: Mapped[Optional[str]] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
comment="Display title for chat sessions; guided sessions use problem_summary",
|
||||
)
|
||||
|
||||
# ── Intake ──
|
||||
intake_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="free_text"
|
||||
|
||||
@@ -11,7 +11,12 @@ from pydantic import BaseModel, Field
|
||||
# ── Intake ──
|
||||
|
||||
class AISessionCreateRequest(BaseModel):
|
||||
"""Start a new FlowPilot session."""
|
||||
"""Start a new FlowPilot or chat session."""
|
||||
session_type: str = Field(
|
||||
"guided",
|
||||
pattern="^(guided|chat)$",
|
||||
description="Session type: guided (FlowPilot) or chat (assistant)",
|
||||
)
|
||||
intake_type: str = Field(
|
||||
"free_text",
|
||||
pattern="^(free_text|psa_ticket|screenshot|log_paste|combined)$",
|
||||
@@ -192,6 +197,8 @@ class LinkTicketRequest(BaseModel):
|
||||
class AISessionSummary(BaseModel):
|
||||
"""Compact session for list views."""
|
||||
id: UUID
|
||||
session_type: str = "guided"
|
||||
title: str | None = None
|
||||
status: str
|
||||
intake_type: str
|
||||
problem_summary: str | None = None
|
||||
@@ -208,7 +215,7 @@ class AISessionSummary(BaseModel):
|
||||
|
||||
|
||||
class AISessionDetail(AISessionSummary):
|
||||
"""Full session detail with steps."""
|
||||
"""Full session detail with steps (guided) or messages (chat)."""
|
||||
intake_content: dict[str, Any]
|
||||
matched_flow_id: UUID | None = None
|
||||
match_score: float | None = None
|
||||
@@ -220,10 +227,32 @@ class AISessionDetail(AISessionSummary):
|
||||
psa_connection_id: UUID | None = None
|
||||
ticket_data: dict[str, Any] | None = None
|
||||
steps: list[AISessionStepResponse] = []
|
||||
conversation_messages: list[dict[str, Any]] = [] # Chat sessions store messages here
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Chat session ──
|
||||
|
||||
class ChatSessionCreateResponse(BaseModel):
|
||||
"""Response after creating a chat session on ai_sessions."""
|
||||
session_id: UUID
|
||||
session_type: str = "chat"
|
||||
title: str
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class ChatMessageRequest(BaseModel):
|
||||
"""Send a message in a chat session."""
|
||||
message: str = Field(..., min_length=1, max_length=8000)
|
||||
|
||||
|
||||
class ChatMessageResponse(BaseModel):
|
||||
"""AI response to a chat message."""
|
||||
content: str
|
||||
suggested_flows: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
class AISessionSearchResult(BaseModel):
|
||||
"""Lightweight session result for Command Palette / autocomplete."""
|
||||
id: UUID
|
||||
|
||||
125
backend/app/services/unified_chat_service.py
Normal file
125
backend/app/services/unified_chat_service.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Unified chat service — chat sessions on ai_sessions table.
|
||||
|
||||
Replaces assistant_chat_service for new chat sessions. Messages are stored
|
||||
in ai_sessions.conversation_messages JSONB. Reuses the same AI calling
|
||||
infrastructure and system prompt from assistant_chat_service.
|
||||
"""
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.ai_session import AISession
|
||||
from app.services.assistant_chat_service import (
|
||||
ASSISTANT_SYSTEM_PROMPT,
|
||||
_call_ai,
|
||||
_auto_title,
|
||||
)
|
||||
from app.services.rag_service import search as rag_search, build_rag_context, extract_suggested_flows
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_chat_session(
|
||||
user_id: UUID,
|
||||
account_id: UUID,
|
||||
team_id: UUID | None,
|
||||
intake_content: dict[str, Any],
|
||||
db: AsyncSession,
|
||||
) -> AISession:
|
||||
"""Create a new chat session on ai_sessions."""
|
||||
first_message = intake_content.get("text", "")
|
||||
title = _auto_title(first_message) if first_message else "New Chat"
|
||||
|
||||
session = AISession(
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
team_id=team_id,
|
||||
session_type="chat",
|
||||
title=title,
|
||||
intake_type="free_text",
|
||||
intake_content=intake_content,
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
confidence_score=0.0,
|
||||
conversation_messages=[],
|
||||
)
|
||||
db.add(session)
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
|
||||
async def send_chat_message(
|
||||
session_id: UUID,
|
||||
user_id: UUID,
|
||||
account_id: UUID,
|
||||
message: str,
|
||||
db: AsyncSession,
|
||||
) -> tuple[str, list[dict[str, Any]], AISession]:
|
||||
"""Send a message in a chat session and get AI response.
|
||||
|
||||
Returns (ai_content, suggested_flows, session).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AISession).where(
|
||||
AISession.id == session_id,
|
||||
AISession.user_id == user_id,
|
||||
AISession.session_type == "chat",
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise ValueError("Chat session not found")
|
||||
|
||||
if session.status not in ("active", "paused"):
|
||||
raise ValueError(f"Cannot send messages to a {session.status} session")
|
||||
|
||||
# Auto-title from first message if still default
|
||||
if session.step_count == 0 and message.strip():
|
||||
session.title = _auto_title(message)
|
||||
|
||||
# Auto-detect problem domain from first message
|
||||
if not session.problem_summary and message.strip():
|
||||
session.problem_summary = _auto_title(message)
|
||||
|
||||
# RAG search for relevant flows
|
||||
rag_results = await rag_search(
|
||||
query=message,
|
||||
account_id=account_id,
|
||||
db=db,
|
||||
limit=8,
|
||||
)
|
||||
rag_context = build_rag_context(rag_results)
|
||||
|
||||
# Build message history for AI
|
||||
ai_messages: list[dict[str, Any]] = []
|
||||
for msg in (session.conversation_messages or []):
|
||||
if msg.get("role") in ("user", "assistant"):
|
||||
ai_messages.append({"role": msg["role"], "content": msg["content"]})
|
||||
|
||||
# Call AI
|
||||
ai_content, input_tokens, output_tokens = await _call_ai(
|
||||
system_base=ASSISTANT_SYSTEM_PROMPT,
|
||||
rag_context=rag_context,
|
||||
history=ai_messages,
|
||||
new_message=message,
|
||||
)
|
||||
|
||||
# Append messages to conversation_messages
|
||||
msgs = list(session.conversation_messages or [])
|
||||
msgs.append({"role": "user", "content": message})
|
||||
msgs.append({"role": "assistant", "content": ai_content})
|
||||
session.conversation_messages = msgs
|
||||
session.step_count += 2 # message count for display
|
||||
session.total_input_tokens += input_tokens
|
||||
session.total_output_tokens += output_tokens
|
||||
|
||||
# Resume if paused
|
||||
if session.status == "paused":
|
||||
session.status = "active"
|
||||
|
||||
suggested_flows = extract_suggested_flows(rag_results)
|
||||
|
||||
return ai_content, suggested_flows, session
|
||||
Reference in New Issue
Block a user