diff --git a/samples/crew-django-advanced/.devcontainer/Dockerfile b/samples/crew-django-advanced/.devcontainer/Dockerfile new file mode 100644 index 00000000..ec4e707f --- /dev/null +++ b/samples/crew-django-advanced/.devcontainer/Dockerfile @@ -0,0 +1 @@ +FROM mcr.microsoft.com/devcontainers/python:3.12-bookworm diff --git a/samples/crew-django-advanced/.devcontainer/devcontainer.json b/samples/crew-django-advanced/.devcontainer/devcontainer.json new file mode 100644 index 00000000..67cac5b2 --- /dev/null +++ b/samples/crew-django-advanced/.devcontainer/devcontainer.json @@ -0,0 +1,11 @@ +{ + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + "features": { + "ghcr.io/defanglabs/devcontainer-feature/defang-cli:1.0.4": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/aws-cli:1": {} + } +} \ No newline at end of file diff --git a/samples/crew-django-advanced/.github/workflows/deploy.yaml b/samples/crew-django-advanced/.github/workflows/deploy.yaml new file mode 100644 index 00000000..3541e59b --- /dev/null +++ b/samples/crew-django-advanced/.github/workflows/deploy.yaml @@ -0,0 +1,27 @@ +name: Deploy + +on: + push: + branches: + - main + +jobs: + deploy: + environment: playground + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Deploy + uses: DefangLabs/defang-github-action@v1.1.3 + with: + config-env-vars: DJANGO_SECRET_KEY POSTGRES_PASSWORD SSL_MODE + env: + DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + SSL_MODE: ${{ secrets.SSL_MODE }} \ No newline at end of file diff --git a/samples/crew-django-advanced/.gitignore b/samples/crew-django-advanced/.gitignore new file mode 100644 index 00000000..0f22146a --- /dev/null +++ b/samples/crew-django-advanced/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# Django +*.log +staticfiles/ +media/ +db.sqlite3 + +# Node +node_modules/ +.next/ +.env + +# Docker +pgdata/ diff --git a/samples/crew-django-advanced/README.md b/samples/crew-django-advanced/README.md new file mode 100644 index 00000000..9e74d000 --- /dev/null +++ b/samples/crew-django-advanced/README.md @@ -0,0 +1,90 @@ +# Crew.ai Advanced Django Sample + +[![1-click-deploy](https://raw.githubusercontent.com/DefangLabs/defang-assets/main/Logos/Buttons/SVG/deploy-with-defang.svg)](https://portal.defang.dev/redirect?url=https%3A%2F%2Fgithub.com%2Fnew%3Ftemplate_name%3Dsample-crew-django-redis-postgres-template%26template_owner%3DDefangSamples) + +This sample builds upon the basic CrewAI example and demonstrates a +multi-agent workflow. A lightweight classifier determines whether the user is +requesting a summary, a deeper research answer or a translation. Depending on +the decision different agents – powered by multiple LLM sizes – are executed +and the progress is streamed back to the browser using Django Channels. + +## Prerequisites + +1. Download [Defang CLI](https://github.com/DefangLabs/defang) +2. (Optional) If you are using [Defang BYOC](https://docs.defang.io/docs/concepts/defang-byoc) authenticate with your cloud provider account +3. (Optional for local development) [Docker CLI](https://docs.docker.com/engine/install/) + +## Development + +To run the application locally, you can use the following command: + +```bash +docker compose -f ./compose.local.yaml up --build +``` + +## Configuration + +For this sample, you will need to provide the following [configuration](https://docs.defang.io/docs/concepts/configuration): + +> Note that if you are using the 1-click deploy option, you can set these values as secrets in your GitHub repository and the action will automatically deploy them for you. + +### `POSTGRES_PASSWORD` +The password for the Postgres database. +```bash +defang config set POSTGRES_PASSWORD +``` + +### `SSL_MODE` + +The SSL mode for the Postgres database. +```bash +defang config set SSL_MODE +``` + +### `DJANGO_SECRET_KEY` + +The secret key for the Django application. +```bash +defang config set DJANGO_SECRET_KEY +``` + +### LLM configuration + +Three different LLM endpoints are used to demonstrate branching. Configure them via: + +```bash +defang config set SMALL_LLM_URL +defang config set SMALL_LLM_MODEL +defang config set MEDIUM_LLM_URL +defang config set MEDIUM_LLM_MODEL +defang config set LARGE_LLM_URL +defang config set LARGE_LLM_MODEL +``` + +In addition the embedding model is configured with `EMBEDDING_URL` and `EMBEDDING_MODEL`. + +## Deployment + +> [!NOTE] +> Download [Defang CLI](https://github.com/DefangLabs/defang) + +### Defang Playground + +Deploy your application to the Defang Playground by opening up your terminal and typing: +```bash +defang compose up +``` + +### BYOC + +If you want to deploy to your own cloud account, you can [use Defang BYOC](https://docs.defang.io/docs/tutorials/deploy-to-your-cloud). + +--- + +Title: Crew.ai Advanced Django Sample + +Short Description: Demonstrates branching CrewAI workflows with multiple LLM sizes to handle summarisation, research and translation requests. + +Tags: Django, Celery, Redis, Postgres, AI, ML, CrewAI + +Languages: Python diff --git a/samples/crew-django-advanced/app/.dockerignore b/samples/crew-django-advanced/app/.dockerignore new file mode 100644 index 00000000..74a7bf9e --- /dev/null +++ b/samples/crew-django-advanced/app/.dockerignore @@ -0,0 +1,27 @@ +# Default .dockerignore file for Defang +**/__pycache__ +**/.direnv +**/.DS_Store +**/.envrc +**/.git +**/.github +**/.idea +**/.next +**/.vscode +**/compose.*.yaml +**/compose.*.yml +**/compose.yaml +**/compose.yml +**/docker-compose.*.yaml +**/docker-compose.*.yml +**/docker-compose.yaml +**/docker-compose.yml +**/node_modules +**/Thumbs.db +Dockerfile +*.Dockerfile +# Ignore our own binary, but only in the root to avoid ignoring subfolders +defang +defang.exe +# Ignore our project-level state +.defang \ No newline at end of file diff --git a/samples/crew-django-advanced/app/Dockerfile b/samples/crew-django-advanced/app/Dockerfile new file mode 100644 index 00000000..890456ab --- /dev/null +++ b/samples/crew-django-advanced/app/Dockerfile @@ -0,0 +1,21 @@ +# Dockerfile for Django API/Worker +FROM python:3.11-slim + +# install curl +RUN apt-get update && apt-get install -y curl \ + && rm -rf /var/lib/apt/lists/* + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --upgrade pip && pip install -r requirements.txt + +COPY . . + +RUN python manage.py collectstatic --noinput + +RUN chmod +x run.sh + +CMD ["./run.sh"] diff --git a/samples/crew-django-advanced/app/config/__init__.py b/samples/crew-django-advanced/app/config/__init__.py new file mode 100644 index 00000000..53f4ccb1 --- /dev/null +++ b/samples/crew-django-advanced/app/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/samples/crew-django-advanced/app/config/asgi.py b/samples/crew-django-advanced/app/config/asgi.py new file mode 100644 index 00000000..89e7625a --- /dev/null +++ b/samples/crew-django-advanced/app/config/asgi.py @@ -0,0 +1,28 @@ +""" +ASGI config for config project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from channels.routing import ProtocolTypeRouter, URLRouter +from django.core.asgi import get_asgi_application +from channels.auth import AuthMiddlewareStack +import os + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +import core.routing + +application = ProtocolTypeRouter({ + "http": get_asgi_application(), + "websocket": AuthMiddlewareStack( + URLRouter( + core.routing.websocket_urlpatterns + ) + ), +}) diff --git a/samples/crew-django-advanced/app/config/celery.py b/samples/crew-django-advanced/app/config/celery.py new file mode 100644 index 00000000..19f9eee7 --- /dev/null +++ b/samples/crew-django-advanced/app/config/celery.py @@ -0,0 +1,8 @@ +import os +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +app = Celery("config") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/samples/crew-django-advanced/app/config/settings.py b/samples/crew-django-advanced/app/config/settings.py new file mode 100644 index 00000000..eb1b6ab3 --- /dev/null +++ b/samples/crew-django-advanced/app/config/settings.py @@ -0,0 +1,193 @@ +""" +Django settings for config project. + +Generated by 'django-admin startproject' using Django 5.1.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from pathlib import Path +from socket import gethostname, gethostbyname_ex +import ipaddress +import os + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") + + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv("DEBUG", False) == "True" + + +def get_private_ips(): + try: + hostname = gethostname() + _, _, ips = gethostbyname_ex(hostname) + return [ip for ip in ips if ipaddress.ip_address(ip).is_private] + except: + return [] # or return a default list of IPs + +ALLOWED_HOSTS = [ + '.defang.app', + 'localhost', + '*', # TODO: REMOVE THIS WHEN GOING TO PRODUCTION +] # Add your own domain name + +ALLOWED_HOSTS += get_private_ips() # Add private IPs so the health check can pass + +if DEBUG: + ALLOWED_HOSTS = ["*"] + + +# Application definition + +INSTALLED_APPS = [ + 'daphne', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'channels', + 'core', + 'corsheaders', +] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +ASGI_APPLICATION = 'config.asgi.application' +WSGI_APPLICATION = 'config.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +# parse DATABASE_URL +import dj_database_url +DATABASES = { + 'default': dj_database_url.config(default=os.environ.get('DATABASE_URL')), +} + + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = '/static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +REDIS_URL = os.environ.get("REDIS_URL") + +import urllib.parse + +parsed_url = urllib.parse.urlparse(REDIS_URL) + +REDIS_HOST = parsed_url.hostname +REDIS_PORT = parsed_url.port + +# Channels/Redis +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [(REDIS_HOST, REDIS_PORT)], + }, + }, +} + +# Celery +CELERY_BROKER_URL = REDIS_URL +CELERY_RESULT_BACKEND = REDIS_URL + +# CrewAI (no special settings needed for hello world) + +# CORS settings +if DEBUG: + CORS_ALLOWED_ORIGINS = [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:8000", + "http://127.0.0.1:8000", + ] +else: + CORS_ALLOWED_ORIGIN_REGEXES = [ + r"^https:\/\/(.*\.)?defang\.app$", + ] + diff --git a/samples/crew-django-advanced/app/config/urls.py b/samples/crew-django-advanced/app/config/urls.py new file mode 100644 index 00000000..f171f70e --- /dev/null +++ b/samples/crew-django-advanced/app/config/urls.py @@ -0,0 +1,24 @@ +""" +URL configuration for config project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path +from core.views import home + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', home, name='home'), +] diff --git a/samples/crew-django-advanced/app/config/wsgi.py b/samples/crew-django-advanced/app/config/wsgi.py new file mode 100644 index 00000000..e232e566 --- /dev/null +++ b/samples/crew-django-advanced/app/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/samples/crew-django-advanced/app/core/__init__.py b/samples/crew-django-advanced/app/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples/crew-django-advanced/app/core/admin.py b/samples/crew-django-advanced/app/core/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/samples/crew-django-advanced/app/core/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/samples/crew-django-advanced/app/core/ai_clients.py b/samples/crew-django-advanced/app/core/ai_clients.py new file mode 100644 index 00000000..414738c5 --- /dev/null +++ b/samples/crew-django-advanced/app/core/ai_clients.py @@ -0,0 +1,49 @@ +"""Utility helpers for accessing OpenAI-compatible endpoints. + +This module provides thin wrappers around the ``openai`` SDK so that the rest +of the codebase does not need to repeatedly parse environment variables. The +existing sample supported a single LLM endpoint via ``LLM_URL`` and +``LLM_MODEL``. For the advanced sample we support multiple LLM sizes (small, +medium and large) by allowing a configurable environment variable prefix. + +Example:: + + get_llm_client() # uses LLM_URL / LLM_MODEL + get_llm_client(prefix="SMALL_LLM") # uses SMALL_LLM_URL / SMALL_LLM_MODEL +""" + +from openai import OpenAI +import os + + +def get_llm_client(prefix: str = "LLM") -> OpenAI: + """Return an OpenAI client for the given prefix. + + Parameters + ---------- + prefix: str + Environment variable prefix. For example ``SMALL_LLM`` will read + ``SMALL_LLM_URL`` and ``SMALL_LLM_MODEL``. + """ + + url = os.getenv(f"{prefix}_URL") + model = os.getenv(f"{prefix}_MODEL") + + # These come as a pair from the Docker Model Runner configuration. + if not url or not model: + raise ValueError(f"{prefix}_URL and {prefix}_MODEL must be set") + + client = OpenAI(base_url=url) + return client + + +def get_embedding_client(): + url = os.getenv("EMBEDDING_URL") + model = os.getenv("EMBEDDING_MODEL") + + # we check for url and model because these come together + # with the docker runner + if not url or not model: + raise ValueError("EMBEDDING_URL and EMBEDDING_MODEL must be set") + client = OpenAI(base_url=url) + return client diff --git a/samples/crew-django-advanced/app/core/apps.py b/samples/crew-django-advanced/app/core/apps.py new file mode 100644 index 00000000..c0ce093b --- /dev/null +++ b/samples/crew-django-advanced/app/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core" diff --git a/samples/crew-django-advanced/app/core/consumers.py b/samples/crew-django-advanced/app/core/consumers.py new file mode 100644 index 00000000..387c73b4 --- /dev/null +++ b/samples/crew-django-advanced/app/core/consumers.py @@ -0,0 +1,37 @@ +import json +from channels.generic.websocket import AsyncWebsocketConsumer +from asgiref.sync import async_to_sync +from django.conf import settings +from .tasks import crewai_advanced_task + +import uuid + + +class SummaryConsumer(AsyncWebsocketConsumer): + async def connect(self): + # Generate a safe, unique group name for this connection + self.group_name = f"summary_{uuid.uuid4().hex}" + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.accept() + + async def disconnect(self, close_code): + await self.channel_layer.group_discard(self.group_name, self.channel_name) + + async def receive(self, text_data=None, bytes_data=None): + data = json.loads(text_data) + text = data.get("text", "") + # Send to celery task (fire and forget) + crewai_advanced_task.delay(text, self.group_name) + await self.send( + text_data=json.dumps( + {"status": "starting", "message": f"Received: {text[:10]}..."} + ) + ) + + async def stream_message(self, event): + # Called by celery worker through channel layer + await self.send( + text_data=json.dumps( + {"status": event["status"], "message": event["message"]} + ) + ) diff --git a/samples/crew-django-advanced/app/core/custom_llm.py b/samples/crew-django-advanced/app/core/custom_llm.py new file mode 100644 index 00000000..ca3a1abf --- /dev/null +++ b/samples/crew-django-advanced/app/core/custom_llm.py @@ -0,0 +1,77 @@ +from typing import Any, Dict, List, Optional, Union +from crewai.llms.base_llm import BaseLLM +import os +from .ai_clients import get_llm_client + +# Default to the standard LLM configuration if no custom prefix is provided. +DEFAULT_ENV_PREFIX = "LLM" + + +def _get_default_model(prefix: str) -> str: + """Helper to fetch the default model for a given prefix.""" + model = os.getenv(f"{prefix}_MODEL") + if not model: + raise ValueError(f"{prefix}_MODEL must be set") + return model + + +class DockerRunnerLLM(BaseLLM): + """ + Custom LLM that uses the OpenAI-compatible API. + Implements CrewAI's BaseLLM interface. We create this because the + Docker Model Runner is not yet supported by CrewAI, because LiteLLM doesn't + recognize the format in which the Docker Model Runner names the models. + """ + + def __init__( + self, env_prefix: str = DEFAULT_ENV_PREFIX, model: Optional[str] = None + ): + if model is None: + model = _get_default_model(env_prefix) + super().__init__(model=model) + + if not isinstance(model, str) or not model: + raise ValueError("Invalid model: must be a non-empty string") + + self.stop: List[str] = [] # Customize stop words if needed + self.client = get_llm_client(prefix=env_prefix) + + def call( + self, + messages: Union[str, List[Dict[str, str]]], + tools: Optional[List[dict]] = None, + callbacks: Optional[List[Any]] = None, + available_functions: Optional[Dict[str, Any]] = None, + ) -> Union[str, Any]: + """Call the LLM with the given messages (CrewAI interface), using OpenAI SDK.""" + # Convert string message to proper format if needed + if isinstance(messages, str): + messages = [{"role": "user", "content": messages}] + try: + kwargs = { + "model": self.model, + "messages": messages, + } + + if tools is not None: + kwargs["tools"] = tools + if self.stop: + kwargs["stop"] = self.stop + # The OpenAI SDK will use the custom url and api_key + response = self.client.chat.completions.create(**kwargs) + content = response.choices[0].message.content + return content + except Exception as e: + raise RuntimeError(f"LLM call failed: {str(e)}") + + def supports_function_calling(self) -> bool: + """Return True if the LLM supports function calling.""" + return False # Set to True if your model supports function calling + + def supports_stop_words(self) -> bool: + """Return True if the LLM supports stop words.""" + return True + + def get_context_window_size(self) -> int: + """Return the context window size of your LLM.""" + return 4096 # Adjust according to your model's capabilities diff --git a/samples/crew-django-advanced/app/core/management/__init__.py b/samples/crew-django-advanced/app/core/management/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/samples/crew-django-advanced/app/core/management/__init__.py @@ -0,0 +1 @@ + diff --git a/samples/crew-django-advanced/app/core/management/commands/__init__.py b/samples/crew-django-advanced/app/core/management/commands/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/samples/crew-django-advanced/app/core/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/samples/crew-django-advanced/app/core/management/commands/create_initial_superuser.py b/samples/crew-django-advanced/app/core/management/commands/create_initial_superuser.py new file mode 100644 index 00000000..cd139917 --- /dev/null +++ b/samples/crew-django-advanced/app/core/management/commands/create_initial_superuser.py @@ -0,0 +1,18 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model + +class Command(BaseCommand): + help = 'Creates a superuser with username "admin", password "password", and email "admin@example.com" if one does not already exist.' + + def handle(self, *args, **options): + User = get_user_model() + username = 'admin' + password = 'password' + email = 'admin@example.com' + + if not User.objects.filter(username=username).exists(): + self.stdout.write(f'Creating superuser: {username}') + User.objects.create_superuser(username, email, password) + self.stdout.write(self.style.SUCCESS(f'Successfully created superuser: {username}')) + else: + self.stdout.write(self.style.WARNING(f'Superuser {username} already exists.')) diff --git a/samples/crew-django-advanced/app/core/migrations/0001_enable_pgvector.py b/samples/crew-django-advanced/app/core/migrations/0001_enable_pgvector.py new file mode 100644 index 00000000..8a80477c --- /dev/null +++ b/samples/crew-django-advanced/app/core/migrations/0001_enable_pgvector.py @@ -0,0 +1,8 @@ +# Generated by Django 4.2.21 on 2025-05-26 11:53 +from django.db import migrations +from pgvector.django import VectorExtension + +class Migration(migrations.Migration): + operations = [ + VectorExtension(), + ] diff --git a/samples/crew-django-advanced/app/core/migrations/0002_initial.py b/samples/crew-django-advanced/app/core/migrations/0002_initial.py new file mode 100644 index 00000000..0a9d1ce2 --- /dev/null +++ b/samples/crew-django-advanced/app/core/migrations/0002_initial.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.21 on 2025-05-26 13:54 + +from django.db import migrations, models +import pgvector.django.vector + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('core', '0001_enable_pgvector'), + ] + + operations = [ + migrations.CreateModel( + name='Summary', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField()), + ('vector', pgvector.django.vector.VectorField(dimensions=1024)), + ], + ), + ] diff --git a/samples/crew-django-advanced/app/core/migrations/0003_rename_vector_summary_embedding.py b/samples/crew-django-advanced/app/core/migrations/0003_rename_vector_summary_embedding.py new file mode 100644 index 00000000..6dee2c34 --- /dev/null +++ b/samples/crew-django-advanced/app/core/migrations/0003_rename_vector_summary_embedding.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.21 on 2025-05-26 15:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='summary', + old_name='vector', + new_name='embedding', + ), + ] diff --git a/samples/crew-django-advanced/app/core/migrations/__init__.py b/samples/crew-django-advanced/app/core/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples/crew-django-advanced/app/core/models.py b/samples/crew-django-advanced/app/core/models.py new file mode 100644 index 00000000..be025af7 --- /dev/null +++ b/samples/crew-django-advanced/app/core/models.py @@ -0,0 +1,8 @@ +from django.db import models +from pgvector.django import VectorField + + +# Create your models here. +class Summary(models.Model): + text = models.TextField() + embedding = VectorField(dimensions=1024) diff --git a/samples/crew-django-advanced/app/core/routing.py b/samples/crew-django-advanced/app/core/routing.py new file mode 100644 index 00000000..f9d398e9 --- /dev/null +++ b/samples/crew-django-advanced/app/core/routing.py @@ -0,0 +1,6 @@ +from django.urls import re_path +from . import consumers + +websocket_urlpatterns = [ + re_path(r"ws/summary/?$", consumers.SummaryConsumer.as_asgi()), +] diff --git a/samples/crew-django-advanced/app/core/tasks.py b/samples/crew-django-advanced/app/core/tasks.py new file mode 100644 index 00000000..2ff34868 --- /dev/null +++ b/samples/crew-django-advanced/app/core/tasks.py @@ -0,0 +1,171 @@ +"""Celery tasks showcasing a multi-agent CrewAI workflow. + +This module implements a branching workflow using several CrewAI agents: +classifier, researcher, writer and translator. Depending on the user's +request the system either performs a simple summarisation, conducts +additional research before writing a response, or translates the input +into another language. + +The goal is to demonstrate how to orchestrate multiple LLMs of different +sizes within a single Celery task while streaming intermediate results +back to the client via Django Channels. +""" + +from celery import shared_task +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +import crewai +import os +from typing import Tuple + +from .custom_llm import DockerRunnerLLM +from .ai_clients import get_embedding_client + + +# --------------------------------------------------------------------------- +# Agent helpers +# --------------------------------------------------------------------------- + + +def _run_classification(user_text: str) -> str: + """Classify the user request using a small LLM. + + Returns one of ``summary``, ``research`` or ``translate``. + """ + llm = DockerRunnerLLM(env_prefix="SMALL_LLM") + agent = crewai.Agent( + role="Classifier", + goal="Decide whether the user wants a summary, research or translation", + backstory="Quickly label the request. Respond with only one word.", + llm=llm, + ) + task = crewai.Task( + description=( + "Classify the following text into one of the words 'summary'," + " 'research' or 'translate' and respond with only that word: " + f"'{user_text}'" + ), + agent=agent, + expected_output="summary | research | translate", + ) + crew = crewai.Crew(agents=[agent], tasks=[task]) + return crew.kickoff().raw.strip().lower() + + +def _run_summary(text: str) -> str: + """Generate a concise summary using the large LLM.""" + llm = DockerRunnerLLM(env_prefix="LARGE_LLM") + agent = crewai.Agent( + role="Summarizer", + goal="Summarise user provided text in one sentence", + backstory="Expert at concise communication.", + llm=llm, + ) + task = crewai.Task( + description=f"Summarise the following text: '{text}'", + agent=agent, + expected_output="One sentence summary", + ) + crew = crewai.Crew(agents=[agent], tasks=[task]) + return crew.kickoff().raw + + +def _run_research_and_write(topic: str) -> str: + """Research a topic then write a short explanation.""" + research_llm = DockerRunnerLLM(env_prefix="MEDIUM_LLM") + writer_llm = DockerRunnerLLM(env_prefix="LARGE_LLM") + research_agent = crewai.Agent( + role="Researcher", + goal="Gather key facts and references about a topic", + backstory="Uses search tools to collect information.", + llm=research_llm, + ) + writer_agent = crewai.Agent( + role="Writer", + goal="Compose a clear answer based on collected facts", + backstory="Excellent at explaining complex topics to a general audience.", + llm=writer_llm, + ) + research_task = crewai.Task( + description=f"Research the topic: '{topic}'", + agent=research_agent, + expected_output="bullet list of key facts", + ) + write_task = crewai.Task( + description="Write a concise paragraph using the research", + agent=writer_agent, + expected_output="brief paragraph", + ) + crew = crewai.Crew( + agents=[research_agent, writer_agent], tasks=[research_task, write_task] + ) + return crew.kickoff().raw + + +def _run_translation(text: str) -> str: + """Translate text into the target language using the medium LLM.""" + llm = DockerRunnerLLM(env_prefix="MEDIUM_LLM") + agent = crewai.Agent( + role="Translator", + goal="Translate English text into Spanish", + backstory="Fluent in many languages and keeps meaning intact.", + llm=llm, + ) + task = crewai.Task( + description=f"Translate the following text into Spanish: '{text}'", + agent=agent, + expected_output="Spanish translation", + ) + crew = crewai.Crew(agents=[agent], tasks=[task]) + return crew.kickoff().raw + + +# --------------------------------------------------------------------------- +# Celery entry point +# --------------------------------------------------------------------------- + + +@shared_task +def crewai_advanced_task(text: str, group_name: str) -> None: + """Entry point for the advanced workflow. + + Streams intermediate status messages back to ``group_name`` so the client + can display progress updates in real time. + """ + channel_layer = get_channel_layer() + + def send(status: str, message: str) -> None: + async_to_sync(channel_layer.group_send)( + group_name, + {"type": "stream_message", "status": status, "message": message}, + ) + + send("processing", f"Classifying request: {text[:20]}...") + decision = _run_classification(text) + send("info", f"Classifier decision: {decision}") + + if decision == "summary": + result = _run_summary(text) + elif decision == "research": + result = _run_research_and_write(text) + else: + result = _run_translation(text) + + send("done", result) + + # Optionally store summary embeddings for similarity search + if decision == "summary": + embedding_client = get_embedding_client() + embedding = ( + embedding_client.embeddings.create( + model=os.getenv("EMBEDDING_MODEL"), input=result + ) + .data[0] + .embedding + ) + from .models import Summary + + try: + Summary.objects.create(text=result, embedding=embedding) + except Exception as exc: # pragma: no cover - best effort + print(exc) diff --git a/samples/crew-django-advanced/app/core/templates/core/home.html b/samples/crew-django-advanced/app/core/templates/core/home.html new file mode 100644 index 00000000..6067db58 --- /dev/null +++ b/samples/crew-django-advanced/app/core/templates/core/home.html @@ -0,0 +1,203 @@ + + + + + Defang WebSocket Demo + {% load static %} + + + + +
+ +
+ +
+ + + +
+ Status: + Disconnected + +
+
+
+ +
+
Messages from backend:
+
+
    +
    +
    +
    + + + diff --git a/samples/crew-django-advanced/app/core/tests.py b/samples/crew-django-advanced/app/core/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/samples/crew-django-advanced/app/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/samples/crew-django-advanced/app/core/views.py b/samples/crew-django-advanced/app/core/views.py new file mode 100644 index 00000000..5a7ce188 --- /dev/null +++ b/samples/crew-django-advanced/app/core/views.py @@ -0,0 +1,7 @@ +from django.shortcuts import render + +# Create your views here. + + +def home(request): + return render(request, "core/home.html") diff --git a/samples/crew-django-advanced/app/dev.sh b/samples/crew-django-advanced/app/dev.sh new file mode 100755 index 00000000..83a32e7d --- /dev/null +++ b/samples/crew-django-advanced/app/dev.sh @@ -0,0 +1,40 @@ +#!/bin/sh + +# Wait for the database to be ready using psycopg 3 and dj-database-url +until python3 -c ' +import os, sys +import psycopg +import dj_database_url + +url = os.environ.get("DATABASE_URL") +if not url: + sys.exit("DATABASE_URL not set") + +try: + cfg = dj_database_url.parse(url) + with psycopg.connect( + dbname=cfg["NAME"], + user=cfg["USER"], + password=cfg["PASSWORD"], + host=cfg["HOST"], + port=cfg["PORT"] or 5432, + ) as conn: + pass # Successful connection +except psycopg.OperationalError: + sys.exit(1) +' +do + echo "Waiting for database to be ready..." + sleep 2 +done + +# Run migrations +python manage.py migrate --noinput +python manage.py create_initial_superuser + +# Note: For Django Channels and ASGI support, you should use an ASGI server like Daphne or Uvicorn. +# The normal runserver is fine for basic development and will use ASGI if available, +# but it is not recommended for production or for full async support. + +# Start Django development server (sufficient for simple local dev/testing) +exec python manage.py runserver 0.0.0.0:8000 diff --git a/samples/crew-django-advanced/app/manage.py b/samples/crew-django-advanced/app/manage.py new file mode 100755 index 00000000..8e7ac79b --- /dev/null +++ b/samples/crew-django-advanced/app/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/samples/crew-django-advanced/app/requirements.txt b/samples/crew-django-advanced/app/requirements.txt new file mode 100644 index 00000000..996722f7 --- /dev/null +++ b/samples/crew-django-advanced/app/requirements.txt @@ -0,0 +1,183 @@ +aiohappyeyeballs==2.6.1 +aiohttp==3.11.18 +aiosignal==1.3.2 +amqp==5.3.1 +annotated-types==0.7.0 +anyio==4.9.0 +appdirs==1.4.4 +asgiref==3.8.1 +asttokens==3.0.0 +attrs==25.3.0 +auth0-python==4.9.0 +autobahn==24.4.2 +Automat==25.4.16 +backoff==2.2.1 +bcrypt==4.3.0 +billiard==4.2.1 +blinker==1.9.0 +build==1.2.2.post1 +cachetools==5.5.2 +celery==5.5.2 +certifi==2025.4.26 +cffi==1.17.1 +channels==4.2.2 +channels_redis==4.2.1 +charset-normalizer==3.4.2 +chromadb==1.0.10 +click==8.1.8 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +coloredlogs==15.0.1 +constantly==23.10.4 +crewai==0.121.0 +cryptography==45.0.2 +daphne==4.2.0 +decorator==5.2.1 +Deprecated==1.2.18 +distro==1.9.0 +dj-database-url==2.3.0 +Django==4.2.21 +django-cors-headers==4.7.0 +docstring_parser==0.16 +durationpy==0.10 +et_xmlfile==2.0.0 +executing==2.2.0 +fastapi==0.115.9 +filelock==3.18.0 +flatbuffers==25.2.10 +frozenlist==1.6.0 +fsspec==2025.5.0 +google-auth==2.40.2 +googleapis-common-protos==1.70.0 +grpcio==1.71.0 +h11==0.16.0 +h2==4.2.0 +hpack==4.1.0 +httpcore==1.0.9 +httptools==0.6.4 +httpx==0.28.1 +huggingface-hub==0.31.4 +humanfriendly==10.0 +hyperframe==6.1.0 +hyperlink==21.0.0 +idna==3.10 +importlib_metadata==8.6.1 +importlib_resources==6.5.2 +incremental==24.7.2 +instructor==1.8.3 +ipython==9.2.0 +ipython_pygments_lexers==1.1.1 +jedi==0.19.2 +Jinja2==3.1.6 +jiter==0.8.2 +json5==0.12.0 +json_repair==0.46.0 +jsonpickle==4.1.0 +jsonref==1.1.0 +jsonschema==4.23.0 +jsonschema-specifications==2025.4.1 +kombu==5.5.3 +kubernetes==32.0.1 +litellm==1.68.0 +markdown-it-py==3.0.0 +MarkupSafe==3.0.2 +matplotlib-inline==0.1.7 +mdurl==0.1.2 +mmh3==5.1.0 +mpmath==1.3.0 +msgpack==1.1.0 +multidict==6.4.4 +networkx==3.4.2 +numpy==2.2.6 +oauthlib==3.2.2 +onnxruntime==1.22.0 +openai==1.75.0 +openpyxl==3.1.5 +opentelemetry-api==1.33.1 +opentelemetry-exporter-otlp-proto-common==1.33.1 +opentelemetry-exporter-otlp-proto-grpc==1.33.1 +opentelemetry-exporter-otlp-proto-http==1.33.1 +opentelemetry-instrumentation==0.54b1 +opentelemetry-instrumentation-asgi==0.54b1 +opentelemetry-instrumentation-fastapi==0.54b1 +opentelemetry-proto==1.33.1 +opentelemetry-sdk==1.33.1 +opentelemetry-semantic-conventions==0.54b1 +opentelemetry-util-http==0.54b1 +orjson==3.10.18 +overrides==7.7.0 +packaging==25.0 +parso==0.8.4 +pdfminer.six==20250327 +pdfplumber==0.11.6 +pexpect==4.9.0 +pgvector==0.4.1 +pillow==11.2.1 +posthog==4.1.0 +priority==1.3.0 +prompt_toolkit==3.0.51 +propcache==0.3.1 +protobuf==5.29.4 +psycopg==3.2.9 +psycopg-binary==3.2.9 +ptyprocess==0.7.0 +pure_eval==0.2.3 +pyasn1==0.6.1 +pyasn1_modules==0.4.2 +pycparser==2.22 +pydantic==2.11.5 +pydantic_core==2.33.2 +Pygments==2.19.1 +PyJWT==2.10.1 +pyOpenSSL==25.1.0 +pypdfium2==4.30.1 +PyPika==0.48.9 +pyproject_hooks==1.2.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.0 +pyvis==0.3.2 +PyYAML==6.0.2 +redis==4.6.0 +referencing==0.36.2 +regex==2024.11.6 +requests==2.32.3 +requests-oauthlib==2.0.0 +rich==13.9.4 +rpds-py==0.25.1 +rsa==4.9.1 +service-identity==24.2.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +sqlparse==0.5.3 +stack-data==0.6.3 +starlette==0.45.3 +sympy==1.14.0 +tenacity==9.1.2 +tiktoken==0.9.0 +tokenizers==0.21.1 +tomli==2.2.1 +tomli_w==1.2.0 +tqdm==4.67.1 +traitlets==5.14.3 +Twisted==24.11.0 +txaio==23.1.1 +typer==0.15.4 +typing-inspection==0.4.1 +typing_extensions==4.13.2 +tzdata==2025.2 +urllib3==2.4.0 +uv==0.7.7 +uvicorn==0.34.2 +uvloop==0.21.0 +vine==5.1.0 +watchfiles==1.0.5 +wcwidth==0.2.13 +websocket-client==1.8.0 +websockets==15.0.1 +whitenoise==6.9.0 +wrapt==1.17.2 +yarl==1.20.0 +zipp==3.21.0 +zope.interface==7.2 diff --git a/samples/crew-django-advanced/app/run.sh b/samples/crew-django-advanced/app/run.sh new file mode 100644 index 00000000..d859646d --- /dev/null +++ b/samples/crew-django-advanced/app/run.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +# Run migrations +python manage.py migrate --noinput +python manage.py create_initial_superuser + +# Start Daphne server +exec daphne -b 0.0.0.0 -p 8000 config.asgi:application diff --git a/samples/crew-django-advanced/compose.local.yaml b/samples/crew-django-advanced/compose.local.yaml new file mode 100644 index 00000000..e6f7ba35 --- /dev/null +++ b/samples/crew-django-advanced/compose.local.yaml @@ -0,0 +1,54 @@ +services: + postgres: + extends: + service: postgres + file: compose.yaml + image: pgvector/pgvector:pg16 + environment: + POSTGRES_PASSWORD: password + volumes: + - pgdata:/var/lib/postgresql/data + + redis: + extends: + service: redis + file: compose.yaml + + app: + extends: + service: app + file: compose.yaml + command: ["./dev.sh"] + environment: + DATABASE_URL: postgres://postgres:password@postgres:5432/postgres + DJANGO_SECRET_KEY: 5k6qi_gzy65bz2soxl6k(rsrw)0z3ws!zmjzsbe+wkp1ovfayg + volumes: + - ./app:/app + + worker: + extends: + service: worker + file: compose.yaml + volumes: + - ./app:/app + command: sh -c "pip install watchdog && watchmedo auto-restart --directory=./ --pattern=*.py --recursive -- celery -A config worker -l info" + environment: + DATABASE_URL: postgres://postgres:password@postgres:5432/postgres + DJANGO_SECRET_KEY: 5k6qi_gzy65bz2soxl6k(rsrw)0z3ws!zmjzsbe+wkp1ovfayg + + llm: + provider: + type: model + options: + model: ai/gemma3:1B-Q4_K_M + x-defang-llm: true + + embedding: + provider: + type: model + options: + model: ai/mxbai-embed-large + x-defang-llm: true + +volumes: + pgdata: diff --git a/samples/crew-django-advanced/compose.yaml b/samples/crew-django-advanced/compose.yaml new file mode 100644 index 00000000..cced977b --- /dev/null +++ b/samples/crew-django-advanced/compose.yaml @@ -0,0 +1,77 @@ +services: + postgres: + image: pgvector/pgvector:pg16 + environment: + POSTGRES_PASSWORD: + x-defang-postgres: true + ports: + - mode: host + target: 5432 + published: 5432 + + redis: + image: redis:6.2 + x-defang-redis: true + ports: + - mode: host + target: 6379 + published: 6379 + + app: + build: + context: ./app + command: ./run.sh + ports: + - "8000:8000" + depends_on: + - postgres + - redis + environment: + DJANGO_SETTINGS_MODULE: config.settings + DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres?sslmode=${SSL_MODE} + REDIS_URL: redis://redis:6379/0 + CELERY_BROKER_URL: redis://redis:6379/0 + CELERY_RESULT_BACKEND: redis://redis:6379/0 + DJANGO_SECRET_KEY: + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 60s + + worker: + build: + context: ./app + command: celery -A config worker -l info + depends_on: + - postgres + - redis + - llm + - embedding + environment: + DJANGO_SETTINGS_MODULE: config.settings + DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres?sslmode=${SSL_MODE} + REDIS_URL: redis://redis:6379/0 + OPENAI_API_KEY: "defang" + DJANGO_SECRET_KEY: + + llm: + provider: + type: model + options: + model: anthropic.claude-3-haiku-20240307-v1:0 + x-defang-llm: true + environment: + OPENAI_API_KEY: "defang" + DEBUG: "true" + + embedding: + provider: + type: model + options: + model: amazon.titan-embed-text-v2:0 + x-defang-llm: true + environment: + OPENAI_API_KEY: "defang" + DEBUG: "true"