Compare commits

..

3 Commits

Author SHA1 Message Date
958dc3bb1f Merge pull request 'feat: conversation-based chat system with anonymous support' (#65) from fix/issue-59 into main 2026-04-14 08:19:54 +02:00
shokollm
5ae8d76bde 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
2026-04-14 04:25:00 +00:00
a9679bbb5d Merge pull request 'refactor: Split conversational.py into modular structure' (#64) from fix/issue-63 into main 2026-04-14 05:57:16 +02:00
6 changed files with 484 additions and 5 deletions

View File

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

View File

@@ -10,6 +10,7 @@ from sqlalchemy import (
ForeignKey, ForeignKey,
Index, Index,
JSON, JSON,
Integer,
) )
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from ..core.database import Base from ..core.database import Base
@@ -30,6 +31,9 @@ class User(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
bots = relationship("Bot", back_populates="user", cascade="all, delete-orphan") bots = relationship("Bot", back_populates="user", cascade="all, delete-orphan")
conversations = relationship(
"Conversation", back_populates="user", cascade="all, delete-orphan"
)
class Bot(Base): class Bot(Base):
@@ -47,7 +51,7 @@ class Bot(Base):
user = relationship("User", back_populates="bots") user = relationship("User", back_populates="bots")
conversations = relationship( conversations = relationship(
"BotConversation", back_populates="bot", cascade="all, delete-orphan" "Conversation", back_populates="bot", cascade="all, delete-orphan"
) )
backtests = relationship( backtests = relationship(
"Backtest", back_populates="bot", cascade="all, delete-orphan" "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") 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): class BotConversation(Base):
__tablename__ = "bot_conversations" __tablename__ = "bot_conversations"
@@ -118,6 +163,9 @@ class Signal(Base):
Index("idx_bots_user_id", Bot.user_id) 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_conversations_bot_id", BotConversation.bot_id)
Index("idx_backtests_bot_id", Backtest.bot_id) Index("idx_backtests_bot_id", Backtest.bot_id)
Index("idx_simulations_bot_id", Simulation.bot_id) Index("idx_simulations_bot_id", Simulation.bot_id)

View File

@@ -242,3 +242,57 @@ class AveChainSwapRequest(BaseModel):
class AveChainSwapResponse(BaseModel): class AveChainSwapResponse(BaseModel):
swap: Optional[dict] = None swap: Optional[dict] = None
upsell_message: Optional[str] = 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

View File

@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address 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.limiter import limiter
from .core.database import engine, Base from .core.database import engine, Base
@@ -15,7 +15,17 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Initialize database on startup.""" """Initialize database on startup."""
# Import all models to ensure they're registered # 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 # Create tables if they don't exist
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -44,6 +54,9 @@ app.add_middleware(
app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(bots.router, prefix="/api/bots", tags=["bots"]) 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(backtest.router, prefix="/api", tags=["backtest"])
app.include_router(simulate.router, prefix="/api", tags=["simulate"]) app.include_router(simulate.router, prefix="/api", tags=["simulate"])
app.include_router(config.router, prefix="/api/config", tags=["config"]) app.include_router(config.router, prefix="/api/config", tags=["config"])

View File

@@ -37,6 +37,50 @@ TOOL_REGISTRY: Dict[str, Any] = {
"example": "Buy PEPE when it drops 10% within 1 hour", "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 <name> [--strategy <strategy_desc>]",
"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 <bot_id>",
"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": [ "ave": [
{ {

View File

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