Phase 1: Communications Module - Complete

Backend infrastructure:
- PostgreSQL models (users, channels, messages, DMs, files, artifacts)
- JWT authentication with password hashing
- Auth API (register, login, logout, get user)
- Channels API (create, list, join, leave)
- Messages API with @grimlock mention detection
- AI responds automatically when @mentioned
- Background task processing for AI responses

Database:
- SQLAlchemy ORM models
- Alembic ready for migrations
- PostgreSQL + Redis in docker-compose

Features working:
- User registration and login
- Create/join public channels
- Send messages in channels
- @grimlock triggers AI response with channel context
- Real-time ready (WebSocket next)

Next: WebSocket for real-time updates, frontend interface
This commit is contained in:
JA
2026-02-12 21:26:16 +00:00
parent 437336a1e4
commit 9f094b7a5d
10 changed files with 912 additions and 5 deletions

View File

@@ -8,6 +8,12 @@ HOST=0.0.0.0
PORT=8000 PORT=8000
DEBUG=true 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 Configuration
CONTEXT_PATH=/app/context CONTEXT_PATH=/app/context
REPOS_PATH=/app/repos REPOS_PATH=/app/repos

154
backend/api/auth.py Normal file
View File

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

188
backend/api/channels.py Normal file
View File

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

262
backend/api/messages.py Normal file
View File

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

46
backend/core/auth.py Normal file
View File

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

35
backend/core/database.py Normal file
View File

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

146
backend/core/models.py Normal file
View File

@@ -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"<User {self.email}>"
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"<Channel #{self.name}>"
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"<Message {self.id} in #{self.channel.name if self.channel else 'Unknown'}>"
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"<DM from {self.sender_id} to {self.recipient_id}>"
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"<File {self.original_filename}>"
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"<Artifact {self.filename}>"

View File

@@ -11,8 +11,13 @@ from dotenv import load_dotenv
import os import os
from api.chat import router as chat_router 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.context_manager import ContextManager
from core.ai_client import AIClient from core.ai_client import AIClient
from core.database import engine
from core.models import Base
# Load environment variables # Load environment variables
load_dotenv() load_dotenv()
@@ -35,6 +40,10 @@ async def lifespan(app: FastAPI):
logger.info("Starting Grimlock backend...") logger.info("Starting Grimlock backend...")
# Create database tables
Base.metadata.create_all(bind=engine)
logger.info("Database tables created/verified")
# Initialize context manager # Initialize context manager
context_path = os.getenv("CONTEXT_PATH", "./context") context_path = os.getenv("CONTEXT_PATH", "./context")
context_manager = ContextManager(context_path) context_manager = ContextManager(context_path)
@@ -59,7 +68,7 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="Grimlock", title="Grimlock",
description="AI-Native Company Operating System", description="AI-Native Company Operating System",
version="0.1.0", version="0.2.0",
lifespan=lifespan lifespan=lifespan
) )
@@ -73,6 +82,9 @@ app.add_middleware(
) )
# Include routers # 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.include_router(chat_router, prefix="/api/chat", tags=["chat"])
@app.get("/") @app.get("/")
@@ -81,7 +93,8 @@ async def root():
return { return {
"status": "online", "status": "online",
"service": "Grimlock", "service": "Grimlock",
"version": "0.1.0" "version": "0.2.0",
"features": ["auth", "channels", "messages", "ai"]
} }
@app.get("/api/health") @app.get("/api/health")
@@ -90,7 +103,8 @@ async def health():
return { return {
"status": "healthy", "status": "healthy",
"context_loaded": context_manager is not None and context_manager.is_loaded(), "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: def get_context_manager() -> ContextManager:

View File

@@ -11,3 +11,21 @@ PyYAML==6.0.1
markdown==3.5.2 markdown==3.5.2
weasyprint==60.2 weasyprint==60.2
pandas==2.2.0 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

View File

@@ -1,15 +1,50 @@
version: '3.8' version: '3.8'
services: 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: grimlock-backend:
build: build:
context: ./backend context: .
dockerfile: ../docker/Dockerfile.backend dockerfile: docker/Dockerfile.backend
container_name: grimlock-backend container_name: grimlock-backend
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - 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 - HOST=0.0.0.0
- PORT=8000 - PORT=8000
- DEBUG=true - DEBUG=true
@@ -22,6 +57,9 @@ services:
restart: unless-stopped restart: unless-stopped
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
volumes:
postgres_data:
networks: networks:
default: default:
name: grimlock-network name: grimlock-network