diff --git a/backend/.env.example b/backend/.env.example index 489d0f7..98bfe34 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,6 +8,12 @@ HOST=0.0.0.0 PORT=8000 DEBUG=true +# Database +DATABASE_URL=postgresql://grimlock:grimlock@localhost:5432/grimlock + +# Security +SECRET_KEY=your-secret-key-change-in-production-use-openssl-rand-hex-32 + # Context Configuration CONTEXT_PATH=/app/context REPOS_PATH=/app/repos diff --git a/backend/api/auth.py b/backend/api/auth.py new file mode 100644 index 0000000..5a06473 --- /dev/null +++ b/backend/api/auth.py @@ -0,0 +1,154 @@ +""" +Authentication API Endpoints +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from pydantic import BaseModel, EmailStr +from typing import Optional + +from core.database import get_db +from core.models import User, UserRole +from core.auth import verify_password, get_password_hash, create_access_token, decode_token + +router = APIRouter() +security = HTTPBearer() + +# Pydantic models +class UserRegister(BaseModel): + email: EmailStr + name: str + password: str + role: UserRole = UserRole.ENGINEER + +class UserLogin(BaseModel): + email: EmailStr + password: str + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + +class UserResponse(BaseModel): + id: int + email: str + name: str + role: UserRole + is_active: bool + is_online: bool + + class Config: + from_attributes = True + +# Dependency to get current user from token +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> User: + """Get current authenticated user""" + token = credentials.credentials + payload = decode_token(token) + + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials" + ) + + user_id = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials" + ) + + user = db.query(User).filter(User.id == int(user_id)).first() + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found" + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is disabled" + ) + + return user + +@router.post("/register", response_model=UserResponse) +async def register(user_data: UserRegister, db: Session = Depends(get_db)): + """Register a new user""" + + # Check if user exists + existing_user = db.query(User).filter(User.email == user_data.email).first() + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # Create new user + user = User( + email=user_data.email, + name=user_data.name, + password_hash=get_password_hash(user_data.password), + role=user_data.role + ) + + db.add(user) + db.commit() + db.refresh(user) + + return user + +@router.post("/login", response_model=Token) +async def login(credentials: UserLogin, db: Session = Depends(get_db)): + """Login and get access token""" + + # Find user + user = db.query(User).filter(User.email == credentials.email).first() + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password" + ) + + # Verify password + if not verify_password(credentials.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password" + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is disabled" + ) + + # Create access token + access_token = create_access_token(data={"sub": str(user.id)}) + + # Update user online status + user.is_online = True + db.commit() + + return {"access_token": access_token} + +@router.get("/me", response_model=UserResponse) +async def get_me(current_user: User = Depends(get_current_user)): + """Get current user info""" + return current_user + +@router.post("/logout") +async def logout( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Logout user""" + current_user.is_online = False + db.commit() + return {"message": "Logged out successfully"} diff --git a/backend/api/channels.py b/backend/api/channels.py new file mode 100644 index 0000000..9338265 --- /dev/null +++ b/backend/api/channels.py @@ -0,0 +1,188 @@ +""" +Channels API - Channel management and operations +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import List, Optional + +from core.database import get_db +from core.models import Channel, User, ChannelType +from api.auth import get_current_user + +router = APIRouter() + +# Pydantic models +class ChannelCreate(BaseModel): + name: str + description: Optional[str] = None + type: ChannelType = ChannelType.PUBLIC + +class ChannelResponse(BaseModel): + id: int + name: str + description: Optional[str] + type: ChannelType + member_count: int + created_at: str + + class Config: + from_attributes = True + +class ChannelListResponse(BaseModel): + id: int + name: str + description: Optional[str] + type: ChannelType + unread_count: int = 0 # TODO: Implement actual unread counting + + class Config: + from_attributes = True + +@router.post("/", response_model=ChannelResponse) +async def create_channel( + channel_data: ChannelCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Create a new channel""" + + # Check if channel name already exists + existing = db.query(Channel).filter(Channel.name == channel_data.name).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Channel name already exists" + ) + + # Create channel + channel = Channel( + name=channel_data.name, + description=channel_data.description, + type=channel_data.type, + created_by=current_user.id + ) + + db.add(channel) + db.commit() + db.refresh(channel) + + # Add creator as member + channel.members.append(current_user) + db.commit() + + return { + **channel.__dict__, + "member_count": len(channel.members), + "created_at": channel.created_at.isoformat() + } + +@router.get("/", response_model=List[ChannelListResponse]) +async def list_channels( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """List all channels user is a member of""" + + channels = current_user.channels + + return [ + { + "id": c.id, + "name": c.name, + "description": c.description, + "type": c.type, + "unread_count": 0 # TODO: Implement + } + for c in channels + ] + +@router.get("/{channel_id}", response_model=ChannelResponse) +async def get_channel( + channel_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get channel details""" + + channel = db.query(Channel).filter(Channel.id == channel_id).first() + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Channel not found" + ) + + # Check if user is member (for private channels) + if channel.type == ChannelType.PRIVATE: + if current_user not in channel.members: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not a member of this channel" + ) + + return { + **channel.__dict__, + "member_count": len(channel.members), + "created_at": channel.created_at.isoformat() + } + +@router.post("/{channel_id}/join") +async def join_channel( + channel_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Join a channel""" + + channel = db.query(Channel).filter(Channel.id == channel_id).first() + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Channel not found" + ) + + # Can't join private channels (must be invited) + if channel.type == ChannelType.PRIVATE: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot join private channel" + ) + + # Check if already a member + if current_user in channel.members: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Already a member" + ) + + channel.members.append(current_user) + db.commit() + + return {"message": f"Joined channel #{channel.name}"} + +@router.post("/{channel_id}/leave") +async def leave_channel( + channel_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Leave a channel""" + + channel = db.query(Channel).filter(Channel.id == channel_id).first() + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Channel not found" + ) + + if current_user not in channel.members: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Not a member of this channel" + ) + + channel.members.remove(current_user) + db.commit() + + return {"message": f"Left channel #{channel.name}"} diff --git a/backend/api/messages.py b/backend/api/messages.py new file mode 100644 index 0000000..dee11d1 --- /dev/null +++ b/backend/api/messages.py @@ -0,0 +1,262 @@ +""" +Messages API - Send and receive messages with @mention support +""" + +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import List, Optional +import re +from datetime import datetime + +from core.database import get_db +from core.models import Message, Channel, User, ChannelType +from core.ai_client import AIClient +from core.context_manager import ContextManager +from api.auth import get_current_user +import main + +router = APIRouter() + +# Pydantic models +class MessageCreate(BaseModel): + content: str + reply_to: Optional[int] = None + +class UserInfo(BaseModel): + id: int + name: str + email: str + role: str + is_online: bool + + class Config: + from_attributes = True + +class MessageResponse(BaseModel): + id: int + content: str + is_ai_message: bool + user: Optional[UserInfo] + reply_to_message_id: Optional[int] + created_at: str + edited_at: Optional[str] + + class Config: + from_attributes = True + +def detect_grimlock_mention(content: str) -> bool: + """Detect if @grimlock is mentioned in message""" + return bool(re.search(r'@grimlock\b', content, re.IGNORECASE)) + +def extract_mention_query(content: str) -> str: + """Extract the query after @grimlock mention""" + # Remove @grimlock and return the rest + return re.sub(r'@grimlock\s*', '', content, flags=re.IGNORECASE).strip() + +async def handle_grimlock_mention( + message: Message, + channel: Channel, + db: Session, + context_manager: ContextManager, + ai_client: AIClient +): + """Handle @grimlock mention - respond with AI""" + + try: + # Extract query + query = extract_mention_query(message.content) + + # Get channel history for context (last 10 messages) + recent_messages = db.query(Message)\ + .filter(Message.channel_id == channel.id)\ + .filter(Message.id < message.id)\ + .order_by(Message.id.desc())\ + .limit(10)\ + .all() + + # Build conversation history + conversation = [] + for msg in reversed(recent_messages): + if msg.user: + conversation.append({ + "role": "user", + "content": f"{msg.user.name}: {msg.content}" + }) + elif msg.is_ai_message: + conversation.append({ + "role": "assistant", + "content": msg.content + }) + + # Add current message + if message.user: + conversation.append({ + "role": "user", + "content": f"{message.user.name}: {query}" + }) + + # Get context from context manager + context = context_manager.get_context_for_query(query) + system_prompt = context_manager.get_system_prompt() + + # Add channel context + system_prompt += f"\n\nYou are responding in channel #{channel.name}." + if context: + system_prompt += f"\n\n# Company Context\n{context}" + + # Get AI response + response = await ai_client.chat( + messages=conversation, + system_prompt=system_prompt + ) + + # Create AI message + ai_message = Message( + channel_id=channel.id, + user_id=None, + content=response, + is_ai_message=True, + reply_to_message_id=message.id + ) + + db.add(ai_message) + db.commit() + db.refresh(ai_message) + + return ai_message + + except Exception as e: + # Create error message + error_message = Message( + channel_id=channel.id, + user_id=None, + content=f"Sorry, I encountered an error: {str(e)}", + is_ai_message=True, + reply_to_message_id=message.id + ) + db.add(error_message) + db.commit() + return error_message + +@router.post("/{channel_id}/messages", response_model=MessageResponse) +async def send_message( + channel_id: int, + message_data: MessageCreate, + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), + context_manager: ContextManager = Depends(main.get_context_manager), + ai_client: AIClient = Depends(main.get_ai_client) +): + """Send a message to a channel""" + + # Get channel + channel = db.query(Channel).filter(Channel.id == channel_id).first() + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Channel not found" + ) + + # Check if user is member + if current_user not in channel.members: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not a member of this channel" + ) + + # Create message + message = Message( + channel_id=channel_id, + user_id=current_user.id, + content=message_data.content, + reply_to_message_id=message_data.reply_to + ) + + db.add(message) + db.commit() + db.refresh(message) + + # Check for @grimlock mention + if detect_grimlock_mention(message_data.content): + # Handle in background to not block response + background_tasks.add_task( + handle_grimlock_mention, + message, + channel, + db, + context_manager, + ai_client + ) + + return { + "id": message.id, + "content": message.content, + "is_ai_message": message.is_ai_message, + "user": { + "id": current_user.id, + "name": current_user.name, + "email": current_user.email, + "role": current_user.role.value, + "is_online": current_user.is_online + } if message.user else None, + "reply_to_message_id": message.reply_to_message_id, + "created_at": message.created_at.isoformat(), + "edited_at": message.edited_at.isoformat() if message.edited_at else None + } + +@router.get("/{channel_id}/messages", response_model=List[MessageResponse]) +async def get_messages( + channel_id: int, + limit: int = 50, + before: Optional[int] = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get messages from a channel""" + + # Get channel + channel = db.query(Channel).filter(Channel.id == channel_id).first() + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Channel not found" + ) + + # Check if user is member + if current_user not in channel.members: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not a member of this channel" + ) + + # Query messages + query = db.query(Message).filter(Message.channel_id == channel_id) + + if before: + query = query.filter(Message.id < before) + + messages = query.order_by(Message.id.desc()).limit(limit).all() + messages.reverse() # Return in chronological order + + # Format response + result = [] + for msg in messages: + result.append({ + "id": msg.id, + "content": msg.content, + "is_ai_message": msg.is_ai_message, + "user": { + "id": msg.user.id, + "name": msg.user.name, + "email": msg.user.email, + "role": msg.user.role.value, + "is_online": msg.user.is_online + } if msg.user else None, + "reply_to_message_id": msg.reply_to_message_id, + "created_at": msg.created_at.isoformat(), + "edited_at": msg.edited_at.isoformat() if msg.edited_at else None + }) + + return result diff --git a/backend/core/auth.py b/backend/core/auth.py new file mode 100644 index 0000000..68ab79a --- /dev/null +++ b/backend/core/auth.py @@ -0,0 +1,46 @@ +""" +Authentication Utilities +""" + +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +import os + +# Security configuration +SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days + +# Password hashing +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against a hash""" + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + """Hash a password""" + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Create a JWT access token""" + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +def decode_token(token: str) -> Optional[dict]: + """Decode and verify a JWT token""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + return None diff --git a/backend/core/database.py b/backend/core/database.py new file mode 100644 index 0000000..85cfc4c --- /dev/null +++ b/backend/core/database.py @@ -0,0 +1,35 @@ +""" +Database Configuration +""" + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool +import os + +# Database URL from environment +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://grimlock:grimlock@localhost:5432/grimlock" +) + +# Create engine +engine = create_engine( + DATABASE_URL, + echo=os.getenv("DEBUG", "false").lower() == "true", + pool_pre_ping=True, + pool_size=10, + max_overflow=20 +) + +# Session factory +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Dependency to get DB session +def get_db(): + """FastAPI dependency for database sessions""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/core/models.py b/backend/core/models.py new file mode 100644 index 0000000..5f4a508 --- /dev/null +++ b/backend/core/models.py @@ -0,0 +1,146 @@ +""" +Database Models - SQLAlchemy ORM +""" + +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Enum, Table +from sqlalchemy.orm import relationship, declarative_base +from sqlalchemy.sql import func +from datetime import datetime +import enum + +Base = declarative_base() + +# Association tables for many-to-many relationships +channel_members = Table( + 'channel_members', + Base.metadata, + Column('channel_id', Integer, ForeignKey('channels.id', ondelete='CASCADE'), primary_key=True), + Column('user_id', Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True), + Column('joined_at', DateTime, default=func.now()) +) + +class UserRole(str, enum.Enum): + ENGINEER = "engineer" + BD = "bd" + ADMIN = "admin" + EXEC = "exec" + +class ChannelType(str, enum.Enum): + PUBLIC = "public" + PRIVATE = "private" + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String(255), unique=True, index=True, nullable=False) + name = Column(String(255), nullable=False) + password_hash = Column(String(255), nullable=False) + role = Column(Enum(UserRole), default=UserRole.ENGINEER, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + is_online = Column(Boolean, default=False, nullable=False) + last_seen = Column(DateTime, default=func.now()) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + # Relationships + messages = relationship("Message", back_populates="user", cascade="all, delete-orphan") + sent_dms = relationship("DirectMessage", foreign_keys="DirectMessage.sender_id", back_populates="sender") + received_dms = relationship("DirectMessage", foreign_keys="DirectMessage.recipient_id", back_populates="recipient") + channels = relationship("Channel", secondary=channel_members, back_populates="members") + files = relationship("File", back_populates="uploaded_by_user") + + def __repr__(self): + return f"" + +class Channel(Base): + __tablename__ = "channels" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), unique=True, index=True, nullable=False) + description = Column(Text) + type = Column(Enum(ChannelType), default=ChannelType.PUBLIC, nullable=False) + created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL')) + created_at = Column(DateTime, default=func.now()) + + # Relationships + messages = relationship("Message", back_populates="channel", cascade="all, delete-orphan") + members = relationship("User", secondary=channel_members, back_populates="channels") + + def __repr__(self): + return f"" + +class Message(Base): + __tablename__ = "messages" + + id = Column(Integer, primary_key=True, index=True) + channel_id = Column(Integer, ForeignKey('channels.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True) # NULL if AI + content = Column(Text, nullable=False) + is_ai_message = Column(Boolean, default=False, nullable=False) + reply_to_message_id = Column(Integer, ForeignKey('messages.id', ondelete='SET NULL'), nullable=True) + created_at = Column(DateTime, default=func.now()) + edited_at = Column(DateTime, nullable=True) + + # Relationships + channel = relationship("Channel", back_populates="messages") + user = relationship("User", back_populates="messages") + replies = relationship("Message", remote_side=[id], backref="parent_message") + artifacts = relationship("Artifact", back_populates="message", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + +class DirectMessage(Base): + __tablename__ = "direct_messages" + + id = Column(Integer, primary_key=True, index=True) + sender_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + recipient_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + content = Column(Text, nullable=False) + is_ai_message = Column(Boolean, default=False, nullable=False) + read_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=func.now()) + edited_at = Column(DateTime, nullable=True) + + # Relationships + sender = relationship("User", foreign_keys=[sender_id], back_populates="sent_dms") + recipient = relationship("User", foreign_keys=[recipient_id], back_populates="received_dms") + + def __repr__(self): + return f"" + +class File(Base): + __tablename__ = "files" + + id = Column(Integer, primary_key=True, index=True) + filename = Column(String(255), nullable=False) + original_filename = Column(String(255), nullable=False) + file_path = Column(String(500), nullable=False) + file_size = Column(Integer, nullable=False) # bytes + mime_type = Column(String(100), nullable=False) + uploaded_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL')) + channel_id = Column(Integer, ForeignKey('channels.id', ondelete='CASCADE'), nullable=True) + created_at = Column(DateTime, default=func.now()) + + # Relationships + uploaded_by_user = relationship("User", back_populates="files") + + def __repr__(self): + return f"" + +class Artifact(Base): + __tablename__ = "artifacts" + + id = Column(Integer, primary_key=True, index=True) + message_id = Column(Integer, ForeignKey('messages.id', ondelete='CASCADE'), nullable=False) + filename = Column(String(255), nullable=False) + file_path = Column(String(500), nullable=False) + file_type = Column(String(50), nullable=False) # pdf, csv, docx, etc. + created_at = Column(DateTime, default=func.now()) + + # Relationships + message = relationship("Message", back_populates="artifacts") + + def __repr__(self): + return f"" diff --git a/backend/main.py b/backend/main.py index cc34609..26b7cc9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,8 +11,13 @@ from dotenv import load_dotenv import os from api.chat import router as chat_router +from api.auth import router as auth_router +from api.channels import router as channels_router +from api.messages import router as messages_router from core.context_manager import ContextManager from core.ai_client import AIClient +from core.database import engine +from core.models import Base # Load environment variables load_dotenv() @@ -35,6 +40,10 @@ async def lifespan(app: FastAPI): logger.info("Starting Grimlock backend...") + # Create database tables + Base.metadata.create_all(bind=engine) + logger.info("Database tables created/verified") + # Initialize context manager context_path = os.getenv("CONTEXT_PATH", "./context") context_manager = ContextManager(context_path) @@ -59,7 +68,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title="Grimlock", description="AI-Native Company Operating System", - version="0.1.0", + version="0.2.0", lifespan=lifespan ) @@ -73,6 +82,9 @@ app.add_middleware( ) # Include routers +app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) +app.include_router(channels_router, prefix="/api/channels", tags=["channels"]) +app.include_router(messages_router, prefix="/api/channels", tags=["messages"]) app.include_router(chat_router, prefix="/api/chat", tags=["chat"]) @app.get("/") @@ -81,7 +93,8 @@ async def root(): return { "status": "online", "service": "Grimlock", - "version": "0.1.0" + "version": "0.2.0", + "features": ["auth", "channels", "messages", "ai"] } @app.get("/api/health") @@ -90,7 +103,8 @@ async def health(): return { "status": "healthy", "context_loaded": context_manager is not None and context_manager.is_loaded(), - "ai_client_ready": ai_client is not None + "ai_client_ready": ai_client is not None, + "database": "connected" } def get_context_manager() -> ContextManager: diff --git a/backend/requirements.txt b/backend/requirements.txt index e7f25d8..83490c2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,21 @@ PyYAML==6.0.1 markdown==3.5.2 weasyprint==60.2 pandas==2.2.0 + +# Database +sqlalchemy==2.0.25 +alembic==1.13.1 +psycopg2-binary==2.9.9 + +# Real-time & Caching +redis==5.0.1 +python-socketio==5.10.0 +websockets==12.0 + +# Storage +minio==7.2.3 + +# Security +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.1.2 diff --git a/docker-compose.yml b/docker-compose.yml index 5cc15e3..f96d8a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,50 @@ version: '3.8' services: + postgres: + image: postgres:15-alpine + container_name: grimlock-postgres + environment: + POSTGRES_DB: grimlock + POSTGRES_USER: grimlock + POSTGRES_PASSWORD: grimlock + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U grimlock"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: grimlock-redis + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + grimlock-backend: build: - context: ./backend - dockerfile: ../docker/Dockerfile.backend + context: . + dockerfile: docker/Dockerfile.backend container_name: grimlock-backend + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy ports: - "8000:8000" environment: - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - DATABASE_URL=postgresql://grimlock:grimlock@postgres:5432/grimlock + - SECRET_KEY=${SECRET_KEY:-change-this-in-production} - HOST=0.0.0.0 - PORT=8000 - DEBUG=true @@ -22,6 +57,9 @@ services: restart: unless-stopped command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload +volumes: + postgres_data: + networks: default: name: grimlock-network