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 + +[](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 @@ + + +
+ +