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,
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)

View File

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

View File

@@ -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,7 +15,17 @@ 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)
@@ -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"])

View File

@@ -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 <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": [
{

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