From 5ae8d76bde67e4b295b5f54f48bba11cdb54de8d Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Tue, 14 Apr 2026 04:25:00 +0000 Subject: [PATCH] feat: implement conversation-based chat system with anonymous support - Add Conversation model with user/anonymous_token/bot_id fields - Add Message model linked to conversations - Add AnonymousUser model for tracking anonymous chat limits - Create /api/conversations endpoints (list, create, get, delete) - Add POST /api/conversations/{id}/chat for messaging - Add POST /api/conversations/{id}/set-bot for linking bot - Implement rate limiter with system-wide (500/5hrs) and anonymous limits - Anonymous users: max 50 chats, max 1 bot, max 1 backtest - Add warning after 40 anonymous messages - Register conversations router in main.py - Add create_bot, list_bots, set_bot, get_bot_info tools to registry --- src/backend/app/api/conversations.py | 225 +++++++++++++++++++++ src/backend/app/db/models.py | 50 ++++- src/backend/app/db/schemas.py | 54 +++++ src/backend/app/main.py | 21 +- src/backend/app/services/ai_agent/tools.py | 44 ++++ src/backend/app/services/rate_limiter.py | 95 +++++++++ 6 files changed, 484 insertions(+), 5 deletions(-) create mode 100644 src/backend/app/api/conversations.py create mode 100644 src/backend/app/services/rate_limiter.py diff --git a/src/backend/app/api/conversations.py b/src/backend/app/api/conversations.py new file mode 100644 index 0000000..217c004 --- /dev/null +++ b/src/backend/app/api/conversations.py @@ -0,0 +1,225 @@ +import secrets +from fastapi import APIRouter, Depends, HTTPException, status, Request, Response +from sqlalchemy.orm import Session +from typing import List, Optional, Annotated + +from ..core.database import get_db +from ..db.models import Conversation, Message, User, AnonymousUser, Bot +from ..services.auth import get_current_user +from ..services.rate_limiter import RateLimiter +from ..services.ai_agent import get_conversational_agent + +router = APIRouter(prefix="/api/conversations", tags=["conversations"]) + + +def get_or_create_anonymous_token( + request: Request, response: Response, db: Session +) -> str: + token = request.cookies.get("anonymous_token") + if not token: + token = secrets.token_urlsafe(32) + response.set_cookie( + key="anonymous_token", + value=token, + max_age=60 * 60 * 24 * 365, + httponly=True, + ) + anon = AnonymousUser(id=token) + db.add(anon) + db.commit() + return token + + +@router.get("") +def list_conversations( + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user), +): + if current_user: + return ( + db.query(Conversation) + .filter(Conversation.user_id == current_user.id) + .order_by(Conversation.updated_at.desc()) + .all() + ) + return [] + + +@router.post("") +def create_conversation( + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user), + request: Request = None, + response: Response = None, +): + anonymous_token = None + if not current_user and request: + anonymous_token = get_or_create_anonymous_token(request, response, db) + + conversation = Conversation( + user_id=current_user.id if current_user else None, + anonymous_token=anonymous_token, + ) + db.add(conversation) + db.commit() + db.refresh(conversation) + return conversation + + +@router.get("/{conversation_id}") +def get_conversation( + conversation_id: str, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user), +): + conversation = ( + db.query(Conversation).filter(Conversation.id == conversation_id).first() + ) + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + + if conversation.user_id and conversation.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + return conversation + + +@router.delete("/{conversation_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_conversation( + conversation_id: str, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user), +): + conversation = ( + db.query(Conversation).filter(Conversation.id == conversation_id).first() + ) + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + + if conversation.user_id and conversation.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + db.delete(conversation) + db.commit() + + +@router.post("/{conversation_id}/set-bot") +def set_bot_for_conversation( + conversation_id: str, + bot_id: str, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user), + request: Request = None, +): + conversation = ( + db.query(Conversation).filter(Conversation.id == conversation_id).first() + ) + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + + if conversation.user_id and conversation.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + if not current_user: + anonymous_token = request.cookies.get("anonymous_token") if request else None + if anonymous_token: + RateLimiter.check_anonymous_bot_limit(db, anonymous_token) + + bot = db.query(Bot).filter(Bot.id == bot_id).first() + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + + if current_user and bot.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to use this bot") + + conversation.bot_id = bot_id + db.commit() + + if not current_user and request: + anonymous_token = request.cookies.get("anonymous_token") + if anonymous_token: + RateLimiter.set_bot_created(db, anonymous_token) + + return {"status": "updated", "bot_id": bot_id} + + +@router.post("/{conversation_id}/chat") +def chat_in_conversation( + conversation_id: str, + message: str, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user), + request: Request = None, + response: Response = None, +): + conversation = ( + db.query(Conversation).filter(Conversation.id == conversation_id).first() + ) + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + + if conversation.user_id and conversation.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + warning = None + + if not current_user: + RateLimiter.check_system_limit(db) + + anon_token = get_or_create_anonymous_token(request, response, db) + + anon = RateLimiter.check_anonymous_limit(db, anon_token) + + RateLimiter.increment_chat_count(db, anon_token) + + if anon and anon.chat_count > 40: + warning = "Your progress is not saved." + + conversation_history = ( + db.query(Message) + .filter(Message.conversation_id == conversation_id) + .order_by(Message.created_at) + .all() + ) + history_for_agent = [ + {"role": msg.role, "content": msg.content} for msg in conversation_history[-10:] + ] + + if not conversation.bot_id: + return { + "response": "No bot selected for this conversation. Please set a bot first.", + "thinking": None, + "strategy_config": None, + "success": False, + "warning": warning, + } + + agent = get_conversational_agent(bot_id=conversation.bot_id) + result = agent.chat(message, history_for_agent) + + assistant_content = result.get("response", "I couldn't process your request.") + + user_msg = Message( + conversation_id=conversation_id, + role="user", + content=message, + ) + db.add(user_msg) + + assistant_msg = Message( + conversation_id=conversation_id, + role="assistant", + content=assistant_content, + ) + db.add(assistant_msg) + + conversation.updated_at = conversation.updated_at + db.commit() + + return { + "response": assistant_content, + "thinking": result.get("thinking"), + "strategy_config": result.get("strategy_config"), + "success": result.get("success", False), + "warning": warning, + } diff --git a/src/backend/app/db/models.py b/src/backend/app/db/models.py index 741d95b..f3d8e7a 100644 --- a/src/backend/app/db/models.py +++ b/src/backend/app/db/models.py @@ -10,6 +10,7 @@ from sqlalchemy import ( ForeignKey, Index, JSON, + Integer, ) from sqlalchemy.orm import relationship from ..core.database import Base @@ -30,6 +31,9 @@ class User(Base): updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) bots = relationship("Bot", back_populates="user", cascade="all, delete-orphan") + conversations = relationship( + "Conversation", back_populates="user", cascade="all, delete-orphan" + ) class Bot(Base): @@ -47,7 +51,7 @@ class Bot(Base): user = relationship("User", back_populates="bots") conversations = relationship( - "BotConversation", back_populates="bot", cascade="all, delete-orphan" + "Conversation", back_populates="bot", cascade="all, delete-orphan" ) backtests = relationship( "Backtest", back_populates="bot", cascade="all, delete-orphan" @@ -58,6 +62,47 @@ class Bot(Base): signals = relationship("Signal", back_populates="bot", cascade="all, delete-orphan") +class Conversation(Base): + __tablename__ = "conversations" + + id = Column(String, primary_key=True, default=generate_uuid) + user_id = Column(String, ForeignKey("users.id"), nullable=True) + anonymous_token = Column(String(64), nullable=True) + bot_id = Column(String, ForeignKey("bots.id"), nullable=True) + title = Column(String(255), default="New Conversation") + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + user = relationship("User", back_populates="conversations") + bot = relationship("Bot", back_populates="conversations") + messages = relationship( + "Message", back_populates="conversation", cascade="all, delete-orphan" + ) + + +class Message(Base): + __tablename__ = "messages" + + id = Column(String, primary_key=True, default=generate_uuid) + conversation_id = Column(String, ForeignKey("conversations.id"), nullable=True) + role = Column(String, nullable=False) + content = Column(Text, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + conversation = relationship("Conversation", back_populates="messages") + + +class AnonymousUser(Base): + __tablename__ = "anonymous_users" + + id = Column(String(64), primary_key=True) + chat_count = Column(Integer, default=0) + bot_created = Column(Boolean, default=False) + backtest_count = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + class BotConversation(Base): __tablename__ = "bot_conversations" @@ -118,6 +163,9 @@ class Signal(Base): Index("idx_bots_user_id", Bot.user_id) +Index("idx_conversations_user_id", Conversation.user_id) +Index("idx_conversations_bot_id", Conversation.bot_id) +Index("idx_messages_conversation_id", Message.conversation_id) Index("idx_conversations_bot_id", BotConversation.bot_id) Index("idx_backtests_bot_id", Backtest.bot_id) Index("idx_simulations_bot_id", Simulation.bot_id) diff --git a/src/backend/app/db/schemas.py b/src/backend/app/db/schemas.py index db95b27..c21dd04 100644 --- a/src/backend/app/db/schemas.py +++ b/src/backend/app/db/schemas.py @@ -242,3 +242,57 @@ class AveChainSwapRequest(BaseModel): class AveChainSwapResponse(BaseModel): swap: Optional[dict] = None upsell_message: Optional[str] = None + + +class ConversationResponse(BaseModel): + id: str + user_id: Optional[str] + anonymous_token: Optional[str] + bot_id: Optional[str] + title: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class MessageResponse(BaseModel): + id: str + conversation_id: Optional[str] + role: str + content: str + created_at: datetime + + class Config: + from_attributes = True + + +class ConversationWithMessagesResponse(BaseModel): + id: str + user_id: Optional[str] + anonymous_token: Optional[str] + bot_id: Optional[str] + title: str + created_at: datetime + updated_at: datetime + messages: List[MessageResponse] = [] + + class Config: + from_attributes = True + + +class SetBotRequest(BaseModel): + bot_id: str + + +class ChatRequest(BaseModel): + message: str + + +class ChatResponse(BaseModel): + response: str + thinking: Optional[str] = None + strategy_config: Optional[dict] = None + success: bool = False + warning: Optional[str] = None diff --git a/src/backend/app/main.py b/src/backend/app/main.py index 78a8a6d..fd6055b 100644 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from slowapi import Limiter from slowapi.util import get_remote_address -from .api import auth, bots, backtest, simulate, config, ave +from .api import auth, bots, backtest, simulate, config, ave, conversations from .core.limiter import limiter from .core.database import engine, Base @@ -15,12 +15,22 @@ logger = logging.getLogger(__name__) async def lifespan(app: FastAPI): """Initialize database on startup.""" # Import all models to ensure they're registered - from .db.models import User, Bot, BotConversation, Backtest, Simulation, Signal - + from .db.models import ( + User, + Bot, + BotConversation, + Backtest, + Simulation, + Signal, + Conversation, + Message, + AnonymousUser, + ) + # Create tables if they don't exist Base.metadata.create_all(bind=engine) logger.info("Database initialized successfully") - + yield # Cleanup on shutdown if needed @@ -44,6 +54,9 @@ app.add_middleware( app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(bots.router, prefix="/api/bots", tags=["bots"]) +app.include_router( + conversations.router, prefix="/api/conversations", tags=["conversations"] +) app.include_router(backtest.router, prefix="/api", tags=["backtest"]) app.include_router(simulate.router, prefix="/api", tags=["simulate"]) app.include_router(config.router, prefix="/api/config", tags=["config"]) diff --git a/src/backend/app/services/ai_agent/tools.py b/src/backend/app/services/ai_agent/tools.py index fa714aa..a419c15 100644 --- a/src/backend/app/services/ai_agent/tools.py +++ b/src/backend/app/services/ai_agent/tools.py @@ -37,6 +37,50 @@ TOOL_REGISTRY: Dict[str, Any] = { "example": "Buy PEPE when it drops 10% within 1 hour", }, }, + { + "name": "create_bot", + "description": "Create a new trading bot", + "category": "Randebu Built-in", + "command": None, + "details": { + "description": "Create a new trading bot linked to the current conversation.", + "usage": "create_bot [--strategy ]", + "example": "create_bot MyBot --strategy Buy PEPE when it drops 5%", + }, + }, + { + "name": "list_bots", + "description": "List your trading bots", + "category": "Randebu Built-in", + "command": None, + "details": { + "description": "List all trading bots you own.", + "usage": "list_bots", + "example": "list_bots", + }, + }, + { + "name": "set_bot", + "description": "Set bot for this conversation", + "category": "Randebu Built-in", + "command": None, + "details": { + "description": "Associate a bot with the current conversation.", + "usage": "set_bot ", + "example": "set_bot abc-123-def", + }, + }, + { + "name": "get_bot_info", + "description": "Get current bot details", + "category": "Randebu Built-in", + "command": None, + "details": { + "description": "Get details of the current bot for display in the right pane.", + "usage": "get_bot_info [bot_id]", + "example": "get_bot_info abc-123-def", + }, + }, ], "ave": [ { diff --git a/src/backend/app/services/rate_limiter.py b/src/backend/app/services/rate_limiter.py new file mode 100644 index 0000000..b84582a --- /dev/null +++ b/src/backend/app/services/rate_limiter.py @@ -0,0 +1,95 @@ +import os +from datetime import datetime, timedelta +from sqlalchemy import func +from fastapi import HTTPException +from ..db.models import Message, AnonymousUser + +MAX_CHATS_PER_5HOURS = int(os.getenv("MAX_CHATS_PER_5HOURS", "500")) +MAX_ANONYMOUS_CHATS = 50 +MAX_ANONYMOUS_BOTS = 1 +MAX_ANONYMOUS_BACKTESTS = 1 + + +class RateLimiter: + @staticmethod + def check_system_limit(db): + cutoff = datetime.utcnow() - timedelta(hours=5) + count = ( + db.query(func.count(Message.id)) + .filter(Message.created_at >= cutoff) + .scalar() + ) + + if count >= MAX_CHATS_PER_5HOURS: + raise HTTPException( + status_code=429, + detail="Rate limited from the agent service. Please come back later.", + ) + + @staticmethod + def check_anonymous_limit(db, anonymous_token: str): + anon = ( + db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first() + ) + + if anon and anon.chat_count >= MAX_ANONYMOUS_CHATS: + raise HTTPException( + status_code=403, + detail="You've reached the limit. Please create an account to continue.", + ) + + return anon + + @staticmethod + def check_anonymous_bot_limit(db, anonymous_token: str): + anon = ( + db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first() + ) + + if anon and anon.bot_created: + raise HTTPException( + status_code=403, + detail="You've reached the limit. Please create an account to continue.", + ) + + @staticmethod + def check_anonymous_backtest_limit(db, anonymous_token: str): + anon = ( + db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first() + ) + + if anon and anon.backtest_count >= MAX_ANONYMOUS_BACKTESTS: + raise HTTPException( + status_code=403, + detail="You've reached the limit. Please create an account to continue.", + ) + + @staticmethod + def increment_chat_count(db, anonymous_token: str): + anon = ( + db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first() + ) + + if anon: + anon.chat_count += 1 + db.commit() + + @staticmethod + def set_bot_created(db, anonymous_token: str): + anon = ( + db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first() + ) + + if anon: + anon.bot_created = True + db.commit() + + @staticmethod + def increment_backtest_count(db, anonymous_token: str): + anon = ( + db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first() + ) + + if anon: + anon.backtest_count += 1 + db.commit() -- 2.49.1