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:
2026-03-23 17:29:25 +00:00
parent 72678e7f26
commit b414502062
15 changed files with 685 additions and 88 deletions

View File

@@ -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:

View File

@@ -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"

View File

@@ -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

View 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