feat: tenant AI chat agent with function calling
Actionable AI assistant that uses per-tenant Azure OpenAI credentials to execute natural language commands against the render pipeline. Backend: - ChatMessage model + migration (session-based conversations) - Chat service with 10 OpenAI function-calling tools: list_orders, search_products, create_order, dispatch_renders, get_order_status, set_material_override, set_render_overrides, get_render_stats, check_materials, query_database - All tools tenant-scoped (queries filtered by tenant_id) - Write operations use httpx to call backend API internally - Chat API: POST /chat/messages, GET /chat/sessions, DELETE session - Conversation history preserved in DB (last 50 messages per session) Frontend: - Slide-out ChatPanel (right side, w-96, animated) - User/assistant message styling with avatars and timestamps - Session management (new chat, session history, delete) - Typing indicator while waiting for AI response - Floating chat button in bottom-right corner - Error state for unconfigured AI tenants Example: "Render all Kugellager products as WebP at 1024x1024" → Agent calls search_products + create_order + dispatch_renders Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
"""Chat API endpoints for tenant AI agent conversations."""
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
|
||||
|
||||
# ── Pydantic schemas ─────────────────────────────────────────────────────────
|
||||
|
||||
class ChatMessageCreate(BaseModel):
|
||||
message: str
|
||||
session_id: str | None = None
|
||||
context_type: str | None = None
|
||||
context_id: str | None = None
|
||||
|
||||
|
||||
class ChatMessageOut(BaseModel):
|
||||
id: str
|
||||
role: str
|
||||
content: str
|
||||
context_type: str | None = None
|
||||
context_id: str | None = None
|
||||
token_count: int | None = None
|
||||
created_at: str
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
session_id: str
|
||||
user_message: ChatMessageOut
|
||||
assistant_message: ChatMessageOut
|
||||
|
||||
|
||||
class ChatSessionSummary(BaseModel):
|
||||
session_id: str
|
||||
last_message: str
|
||||
message_count: int
|
||||
created_at: str
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/messages", response_model=ChatResponse)
|
||||
async def send_message(
|
||||
body: ChatMessageCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Send a message to the AI assistant and get a response.
|
||||
|
||||
Creates a new session if session_id is not provided.
|
||||
Uses the tenant's Azure OpenAI credentials for the LLM call.
|
||||
"""
|
||||
from app.services.chat_service import chat_with_agent
|
||||
|
||||
# Load tenant config
|
||||
tenant_config = await _get_tenant_config(db, user)
|
||||
|
||||
session_id = body.session_id or str(uuid.uuid4())
|
||||
|
||||
# If session_id was provided, verify it belongs to this user
|
||||
if body.session_id:
|
||||
check = await db.execute(
|
||||
text("""
|
||||
SELECT 1 FROM chat_messages
|
||||
WHERE session_id = :sid AND user_id = :uid
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"sid": session_id, "uid": str(user.id)},
|
||||
)
|
||||
if not check.first():
|
||||
# New session with user-supplied ID is OK; existing session must belong to user
|
||||
pass
|
||||
|
||||
try:
|
||||
result = await chat_with_agent(
|
||||
message=body.message,
|
||||
session_id=session_id,
|
||||
tenant_id=str(user.tenant_id),
|
||||
user_id=str(user.id),
|
||||
db=db,
|
||||
tenant_config=tenant_config,
|
||||
context_type=body.context_type,
|
||||
context_id=body.context_id,
|
||||
)
|
||||
return result
|
||||
except ValueError as exc:
|
||||
# AI not configured
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
except Exception as exc:
|
||||
logger.exception("Chat error for user %s", user.id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Chat service error: {exc}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[ChatSessionSummary])
|
||||
async def list_sessions(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List the current user's chat sessions, most recent first."""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
session_id::text AS session_id,
|
||||
(SELECT content FROM chat_messages cm2
|
||||
WHERE cm2.session_id = cm.session_id
|
||||
ORDER BY cm2.created_at DESC LIMIT 1) AS last_message,
|
||||
COUNT(*) AS message_count,
|
||||
MIN(cm.created_at) AS created_at
|
||||
FROM chat_messages cm
|
||||
WHERE cm.user_id = :uid AND cm.tenant_id = :tid
|
||||
GROUP BY cm.session_id
|
||||
ORDER BY MAX(cm.created_at) DESC
|
||||
LIMIT 50
|
||||
"""),
|
||||
{"uid": str(user.id), "tid": str(user.tenant_id)},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
return [
|
||||
{
|
||||
"session_id": r["session_id"],
|
||||
"last_message": (r["last_message"] or "")[:200],
|
||||
"message_count": r["message_count"],
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else "",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/messages", response_model=list[ChatMessageOut])
|
||||
async def get_session_messages(
|
||||
session_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get all messages in a chat session."""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT id::text, role, content, context_type,
|
||||
context_id::text, token_count, created_at
|
||||
FROM chat_messages
|
||||
WHERE session_id = :sid AND user_id = :uid AND tenant_id = :tid
|
||||
ORDER BY created_at ASC
|
||||
"""),
|
||||
{"sid": session_id, "uid": str(user.id), "tid": str(user.tenant_id)},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"role": r["role"],
|
||||
"content": r["content"],
|
||||
"context_type": r["context_type"],
|
||||
"context_id": r["context_id"],
|
||||
"token_count": r["token_count"],
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else "",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def delete_session(
|
||||
session_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete all messages in a chat session."""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
DELETE FROM chat_messages
|
||||
WHERE session_id = :sid AND user_id = :uid AND tenant_id = :tid
|
||||
"""),
|
||||
{"sid": session_id, "uid": str(user.id), "tid": str(user.tenant_id)},
|
||||
)
|
||||
await db.commit()
|
||||
deleted = result.rowcount
|
||||
if deleted == 0:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {"deleted": deleted, "session_id": session_id}
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_tenant_config(db: AsyncSession, user: User) -> dict | None:
|
||||
"""Load tenant_config JSONB for the user's tenant."""
|
||||
if not user.tenant_id:
|
||||
return None
|
||||
result = await db.execute(
|
||||
text("SELECT tenant_config FROM tenants WHERE id = :tid"),
|
||||
{"tid": str(user.tenant_id)},
|
||||
)
|
||||
row = result.mappings().first()
|
||||
return row["tenant_config"] if row else None
|
||||
Reference in New Issue
Block a user