Dockerize chat_backend for server-infra deploy (closes #6).
CI / test (pull_request) Successful in 15s
Unit Tests / test (pull_request) Successful in 13s

Add uv packaging, env-driven Django settings, OLLAMA_BASE_URL (LAN GPU
host), DatabaseStorage for prompt/document blobs, compose/CI/deploy
workflows mirroring scha, and ASGI gunicorn+UvicornWorker for WebSockets.
This commit is contained in:
2026-07-25 07:03:10 -05:00
parent 77d7edd0dc
commit de84ff1b00
31 changed files with 5970 additions and 532 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.venv
**/__pycache__/
*.py[cod]
db.sqlite3
llm_be/db.sqlite3
.env
htmlcov/
.pytest_cache/
.mypy_cache/
*.log
staticfiles/
llm_be/staticfiles/
llm_be/media/
llm_be/chroma_db/
chroma_db/
documents/
llm_be/.idea/
+33
View File
@@ -0,0 +1,33 @@
# Local development environment file (copy to .env).
# For production server values, use .env.prod.example instead.
DJANGO_ENV=dev
DJANGO_DEBUG=true
DJANGO_SECRET_KEY=change-me-for-local-development
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
# Optional; when unset, http:// origins are derived for local hosts.
# DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8003,http://127.0.0.1:8003
# Database (docker-compose sets DATABASE_URL for the web service)
DATABASE_URL=postgres://chat_backend:chat_backend@db:5432/chat_backend
# Ollama — local loopback when Ollama runs on this machine; LAN IP for GPU host.
OLLAMA_BASE_URL=http://127.0.0.1:11434
# OLLAMA_MODEL=llama3.2
# OLLAMA_EMBED_MODEL=llama3.2
# Email (SMTP2GO) — optional for local
EMAIL_HOST=mail.smtp2go.com
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_PORT=2525
EMAIL_USE_TLS=true
# Captcha (optional local)
CAPTCHA_SECRET_KEY=
# Gunicorn / ASGI
GUNICORN_WORKERS=2
GUNICORN_BIND=0.0.0.0:8000
# Host port for docker-compose.prod.yml
WEB_PORT=8003
+57
View File
@@ -0,0 +1,57 @@
# Server-side secrets for server-infra deploy.
# Copy to the control node (ai-server-4080 — never commit):
# ~/Documents/secrets/chat_backend/chat_backend_prod.env
# ~/Documents/secrets/chat_backend/chat_backend_beta.env (optional)
#
# server-infra pushes these to /opt/apps/env/chat_backend_<env>.env on each host at deploy time.
#
# Docker Compose: if a secret contains $ (e.g. in DATABASE_URL password), escape each
# $ as $$ or compose will treat $word as a variable (see "pqv1n variable is not set").
# =============================================================================
# PROD template — ports/hosts for production
# =============================================================================
DJANGO_ENV=prod
DJANGO_DEBUG=false
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
DJANGO_ALLOWED_HOSTS=chatbackend.aimloperations.com
# Optional override; when unset, https:// origins are derived from DJANGO_ALLOWED_HOSTS.
# DJANGO_CSRF_TRUSTED_ORIGINS=https://chatbackend.aimloperations.com,https://chat.aimloperations.com
CORS_ALLOWED_ORIGINS=https://chat.aimloperations.com
CORS_ORIGIN_ALLOW_ALL=false
USE_TLS_PROXY=true
# Shared external Postgres (10.0.0.230) — prod database
DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/chat_backend
# Host port on adama/roslin/ai-server-4080 (must match server-infra host_apps)
WEB_PORT=8003
# Ollama on GPU host (ai-server-4080). Firewall must allow 10.0.0.0/24 → :11434.
OLLAMA_BASE_URL=http://10.0.0.128:11434
OLLAMA_MODEL=llama3.2
OLLAMA_EMBED_MODEL=llama3.2
# Email (SMTP2GO)
EMAIL_HOST=mail.smtp2go.com
EMAIL_HOST_USER=replace-with-smtp-user
EMAIL_HOST_PASSWORD=replace-with-smtp-password
EMAIL_PORT=2525
EMAIL_USE_TLS=true
# Captcha
CAPTCHA_SECRET_KEY=replace-with-captcha-secret
# Gunicorn / ASGI (UvicornWorker for WebSockets)
GUNICORN_WORKERS=2
GUNICORN_BIND=0.0.0.0:8000
# =============================================================================
# BETA overrides (use separate file: chat_backend_beta.env)
# =============================================================================
# DJANGO_ENV=beta
# DJANGO_SECRET_KEY=replace-with-a-different-beta-secret
# DJANGO_ALLOWED_HOSTS=beta.chatbackend.aimloperations.com
# DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/chat_backend_beta
# WEB_PORT=8013
# OLLAMA_BASE_URL=http://10.0.0.128:11434
+33
View File
@@ -0,0 +1,33 @@
name: CI
on:
pull_request:
branches: [master]
jobs:
test:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --frozen
- name: Run unit tests
env:
DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key
DJANGO_DEBUG: "true"
DJANGO_ALLOWED_HOSTS: localhost,127.0.0.1,testserver
DATABASE_URL: ""
DB_HOST: ""
SKIP_RAG_INIT: "1"
OLLAMA_BASE_URL: http://127.0.0.1:11434
working-directory: llm_be
run: uv run python manage.py test
+58
View File
@@ -0,0 +1,58 @@
name: Deploy Chat Backend
# Runs after Unit Tests completes on master. Direct pushes only (not PRs).
on:
workflow_run:
workflows: [Unit Tests]
types: [completed]
branches: [master]
jobs:
docker:
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ gitea.event.workflow_run.head_sha }}
- name: Build Docker image
run: docker compose build
# Ephemeral local Postgres only — never inherit host DATABASE_URL (prod).
- name: Run containerized tests
run: |
set -euo pipefail
unset DATABASE_URL DB_HOST DB_NAME DB_USER DB_PASSWORD DB_PORT \
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
DJANGO_ALLOWED_HOSTS OLLAMA_BASE_URL || true
PROJECT="chat-backend-ci-${{ gitea.event.workflow_run.head_sha }}"
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
trap cleanup EXIT
docker compose -p "$PROJECT" up -d --wait db
docker compose -p "$PROJECT" run --rm --no-deps --entrypoint "" \
-e DJANGO_ENV=dev \
-e DJANGO_SECRET_KEY=test-secret-key \
-e DJANGO_DEBUG=true \
-e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver \
-e DATABASE_URL=postgres://chat_backend:chat_backend@db:5432/chat_backend \
-e SKIP_RAG_INIT=1 \
-e OLLAMA_BASE_URL=http://127.0.0.1:11434 \
web uv run python manage.py test
deploy:
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
runs-on: self-hosted
needs: docker
env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
steps:
- name: Deploy chat_backend prod
run: |
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
--app chat_backend \
--env prod \
--ref "${{ gitea.event.workflow_run.head_sha }}"
+35
View File
@@ -0,0 +1,35 @@
name: Unit Tests
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --frozen
- name: Run unit tests
env:
DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key
DJANGO_DEBUG: "true"
DJANGO_ALLOWED_HOSTS: localhost,127.0.0.1,testserver
DATABASE_URL: ""
DB_HOST: ""
SKIP_RAG_INIT: "1"
OLLAMA_BASE_URL: http://127.0.0.1:11434
working-directory: llm_be
run: uv run python manage.py test
+28
View File
@@ -0,0 +1,28 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/app/.venv
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY llm_be ./llm_be
COPY scripts/docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
WORKDIR /app/llm_be
EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
+26 -28
View File
@@ -1,40 +1,38 @@
# Chat Bot Backend
# Chat Backend
## Setup
Django + Channels API for AIML Operations chat (`llm_be/`).
Clone the repo
```console
git clone http://10.0.0.160:3000/AI_ML_Operations_LLC/Chat_Bot_Backend.git
## Local (uv)
```bash
cp .env.example .env
uv sync
cd llm_be
uv run python manage.py migrate
uv run python manage.py runserver 0.0.0.0:8003
```
Go into the repo
```console
cd Chat_Bot_Backend
## Docker (dev compose = bundled Postgres)
```bash
docker compose up --build
```
Create the virtual environment
```console
virtualenv --python=python3.9 venv
```
Prod compose (`docker-compose.prod.yml`) expects external `DATABASE_URL` and
`WEB_PORT` from server-infra secrets. See `.env.prod.example`.
Activate the Virtual Environment
```console
. venv/bin/activate
```
## Ollama
Install the requirments
```console
python -m pip install -r requirements.txt
```
Set `OLLAMA_BASE_URL` (default `http://127.0.0.1:11434` locally).
Deployed envs use the GPU host: `http://10.0.0.128:11434`.
Pre-populate the database with test data
## File storage
Run the dev server
```console
python manage.py
```
Prompt attachments and documents are stored in Postgres (`StoredFile` /
`DatabaseStorage`), not on the container filesystem.
## TODO
## Deploy
- [ ] Create inital database with temp data
- [ ] Do a lot of stuff.....
Gitea Actions on `master``server-infra/scripts/deploy.sh --app chat_backend --env prod`.
Related: [chat_backend#6](https://git.aimloperations.com/ai_ml_operations/chat_backend/issues/6)
+16
View File
@@ -0,0 +1,16 @@
# Production compose for server-infra deploy. No bundled Postgres — use shared
# external DB via DATABASE_URL in .env (see .env.prod.example).
services:
web:
build: .
restart: unless-stopped
ports:
- "${WEB_PORT:-8003}:8000"
env_file:
- .env
volumes:
# Chroma vector index only (uploaded file blobs live in Postgres).
- chroma_data:/app/llm_be/chroma_db
volumes:
chroma_data:
+40
View File
@@ -0,0 +1,40 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: chat_backend
POSTGRES_USER: chat_backend
POSTGRES_PASSWORD: chat_backend
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U chat_backend -d chat_backend"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
web:
build: .
ports:
- "8003:8000"
# No required env_file — CI has no .env. Defaults below; for local secrets:
# docker compose --env-file .env up
#
# Do NOT interpolate ${DATABASE_URL} here. On the Act runner / control node that
# var often points at shared prod/beta Postgres; compose would bake it into
# containerized tests. Use COMPOSE_DATABASE_URL only if you need to override.
environment:
DJANGO_ENV: ${DJANGO_ENV:-dev}
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,testserver}
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://chat_backend:chat_backend@db:5432/chat_backend}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://10.0.0.128:11434}
SKIP_RAG_INIT: ${SKIP_RAG_INIT:-1}
depends_on:
db:
condition: service_healthy
volumes:
postgres_data:
+21 -4
View File
@@ -1,6 +1,8 @@
from django.apps import AppConfig
from django.conf import settings
from django.db import OperationalError
from django.db import OperationalError, ProgrammingError
import os
import sys
class ChatBackendConfig(AppConfig):
@@ -10,14 +12,26 @@ class ChatBackendConfig(AppConfig):
def ready(self):
import chat_backend.signals
# Skip heavy Ollama/Chroma init during migrate/collectstatic/test/CI.
management_cmds = {
"migrate",
"makemigrations",
"collectstatic",
"test",
"shell",
"check",
}
if any(cmd in sys.argv for cmd in management_cmds):
return
if os.environ.get("SKIP_RAG_INIT", "").lower() in {"1", "true", "yes"}:
return
FORCE_RELOAD = False
if True: # not settings.TESTING: # Don't run during tests
try:
from .services.rag_services import AsyncRAGService
from chat_backend.models import Document
# Check if Chroma needs initialization
if Document.objects.exists():
rag_service = AsyncRAGService()
@@ -27,6 +41,9 @@ class ChatBackendConfig(AppConfig):
if FORCE_RELOAD:
print("Force Reload ChromaDB with existing documents...")
rag_service.clear_vector_store()
except OperationalError:
except (OperationalError, ProgrammingError):
# Database tables might not exist yet during migration
pass
except Exception as exc:
# Ollama/Chroma unreachable must not block process start.
print(f"Skipping RAG init at startup: {exc}")
+3 -2
View File
@@ -4,11 +4,12 @@ llama client - Abstract this in the future
import ollama
from typing import List, Dict
from chat_backend.ollama_config import ollama_base_url
class LlamaClient(object):
def __init__(self, model: str = "llama3"):
self.client = ollama.Client(host="http://127.0.0.1:11434")
self.client = ollama.Client(host=ollama_base_url())
self.model = model
def check_if_model_exists(self) -> bool:
@@ -23,7 +24,7 @@ class LlamaClient(object):
return " ".join(raw_response.split()[:4])
def generate_single_message(self, message: str):
return ollama.generate(model=self.model, prompt=message)
return self.client.generate(model=self.model, prompt=message)
def get_chat_response(self, messages: List[str]):
return self.client.chat(model=self.model, messages=messages, stream=False)
+8 -3
View File
@@ -12,8 +12,10 @@ from channels.layers import get_channel_layer
from asgiref.sync import sync_to_async, async_to_sync
from langchain_core.messages import HumanMessage, AIMessage
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import OllamaEmbeddings
from langchain_ollama import OllamaEmbeddings
from langchain_community.tools import DuckDuckGoSearchRun
from chat_backend.ollama_config import ollama_embeddings_kwargs
from django.conf import settings as django_settings
from langchain_core.runnables import RunnableLambda, RunnableBranch, RunnablePassthrough
from langchain_core.tracers.context import collect_runs
@@ -177,9 +179,12 @@ def get_retriever(conversation_id):
logger.info(f"Got conversation: {conversation}")
workspace = DocumentWorkspace.objects.get(company=conversation.user.company)
logger.info(f"Got workspace: {conversation}")
persist_directory = getattr(
django_settings, "CHROMA_PERSIST_DIRECTORY", "./chroma_db/"
)
vectorstore = Chroma(
persist_directory=f"./chroma_db/",
embedding=OllamaEmbeddings(model="llama3.2"),
persist_directory=persist_directory,
embedding=OllamaEmbeddings(**ollama_embeddings_kwargs()),
)
return vectorstore.as_retriever()
@@ -0,0 +1,64 @@
# Generated by Django 6.0 on 2026-07-25 12:01
import chat_backend.storage
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("chat_backend", "0021_alter_prompt_message"),
]
operations = [
migrations.CreateModel(
name="StoredFile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(default=django.utils.timezone.now)),
(
"last_modified",
models.DateTimeField(default=django.utils.timezone.now),
),
("name", models.CharField(db_index=True, max_length=512, unique=True)),
("content", models.BinaryField()),
("size", models.PositiveBigIntegerField(default=0)),
(
"content_type",
models.CharField(blank=True, default="", max_length=255),
),
],
options={
"abstract": False,
},
),
migrations.AlterField(
model_name="document",
name="file",
field=models.FileField(
help_text="uploaded document bytes (stored in database)",
storage=chat_backend.storage.DatabaseStorage(),
upload_to="documents/",
),
),
migrations.AlterField(
model_name="prompt",
name="file",
field=models.FileField(
blank=True,
help_text="file for the prompt (stored in database)",
null=True,
storage=chat_backend.storage.DatabaseStorage(),
upload_to="prompt_files/",
),
),
]
+24 -4
View File
@@ -2,11 +2,11 @@ from django.db import models
from django.contrib.auth.models import AbstractUser
from django.utils import timezone
from autoslug import AutoSlugField
from django.core.files.storage import FileSystemStorage
from chat_backend.storage import DatabaseStorage
# Create your models here.
FILE_STORAGE = FileSystemStorage(location="prompt_files")
DB_FILE_STORAGE = DatabaseStorage()
class TimeInfoBase(models.Model):
@@ -169,7 +169,11 @@ class Prompt(TimeInfoBase):
"Conversation", on_delete=models.CASCADE, blank=True, null=True
)
file = models.FileField(
upload_to=FILE_STORAGE, blank=True, null=True, help_text="file for the prompt"
upload_to="prompt_files/",
storage=DB_FILE_STORAGE,
blank=True,
null=True,
help_text="file for the prompt (stored in database)",
)
file_type = models.CharField(
max_length=16,
@@ -232,7 +236,23 @@ class DocumentWorkspace(TimeInfoBase):
class Document(TimeInfoBase):
workspace = models.ForeignKey(DocumentWorkspace, on_delete=models.CASCADE)
file = models.FileField(upload_to="documents/")
file = models.FileField(
upload_to="documents/",
storage=DB_FILE_STORAGE,
help_text="uploaded document bytes (stored in database)",
)
uploaded_at = models.DateTimeField(auto_now_add=True)
processed = models.BooleanField(default=False)
active = models.BooleanField(default=False)
class StoredFile(TimeInfoBase):
"""Blob store for DatabaseStorage — prompt attachments and documents."""
name = models.CharField(max_length=512, unique=True, db_index=True)
content = models.BinaryField()
size = models.PositiveBigIntegerField(default=0)
content_type = models.CharField(max_length=255, blank=True, default="")
def __str__(self):
return self.name
+30
View File
@@ -0,0 +1,30 @@
"""Shared Ollama client helpers — always use settings.OLLAMA_BASE_URL."""
from django.conf import settings
def ollama_base_url() -> str:
return getattr(settings, "OLLAMA_BASE_URL", "http://127.0.0.1:11434")
def ollama_model(default: str | None = None) -> str:
if default:
return default
return getattr(settings, "OLLAMA_MODEL", "llama3.2")
def ollama_embed_model() -> str:
return getattr(settings, "OLLAMA_EMBED_MODEL", ollama_model())
def ollama_llm_kwargs(**extra):
"""Keyword args for langchain_ollama.OllamaLLM / ChatOllama."""
kwargs = {"base_url": ollama_base_url(), "model": ollama_model()}
kwargs.update(extra)
return kwargs
def ollama_embeddings_kwargs(**extra):
kwargs = {"base_url": ollama_base_url(), "model": ollama_embed_model()}
kwargs.update(extra)
return kwargs
+5 -3
View File
@@ -1,18 +1,20 @@
from abc import ABC, abstractmethod
from langchain_ollama import OllamaLLM
from langchain_core.output_parsers import StrOutputParser
from django.conf import settings
from chat_backend.ollama_config import ollama_llm_kwargs
class BaseService(ABC):
"""Abstract base class for LLM conversation services."""
def __init__(self, temperature=0.7):
self.llm = OllamaLLM(
model="llama3.2" if not settings.DEBUG else "gpt-oss:20b",
temperature=0.7,
**ollama_llm_kwargs(
temperature=temperature,
top_k=50,
top_p=0.9,
repeat_penalty=1.1,
num_ctx=4096,
)
)
self.output_parser = StrOutputParser()
@@ -11,6 +11,7 @@ from langchain_core.output_parsers import StrOutputParser
import docx
import pypdf
from django.conf import settings
from chat_backend.ollama_config import ollama_llm_kwargs
class AsyncDataAnalysisService:
@@ -19,10 +20,11 @@ class AsyncDataAnalysisService:
def __init__(self):
# A model with a large context window and strong analytical skills is best
self.llm = OllamaLLM(
model="llama3.2" if not settings.DEBUG else "gpt-oss:20b",
**ollama_llm_kwargs(
temperature=0.3,
num_ctx=8192,
)
)
self.output_parser = StrOutputParser()
self._setup_chain()
+3 -1
View File
@@ -8,6 +8,7 @@ from langchain_core.prompts import ChatPromptTemplate
from django.conf import settings
from chat_backend.models import Conversation, Prompt
from chat_backend.ollama_config import ollama_llm_kwargs
class LLMService(ABC):
@@ -15,13 +16,14 @@ class LLMService(ABC):
def __init__(self):
self.llm = OllamaLLM(
model="llama3.2" if not settings.DEBUG else "gpt-oss:20b",
**ollama_llm_kwargs(
temperature=0.7,
top_k=50,
top_p=0.9,
repeat_penalty=1.1,
num_ctx=4096,
)
)
self.output_parser = StrOutputParser()
@abstractmethod
@@ -3,6 +3,7 @@ from typing import Dict, Any
from langchain_core.prompts import ChatPromptTemplate
from langchain_ollama import OllamaLLM
from chat_backend.services.base_service import BaseService
from chat_backend.ollama_config import ollama_llm_kwargs
class ModerationLabel(Enum):
@@ -19,11 +20,12 @@ class ModerationClassifier(BaseService):
def __init__(self):
super().__init__(temperature=0.1)
self.llm = OllamaLLM(
model="llama3.2",
**ollama_llm_kwargs(
temperature=0.1, # Very low for strict moderation
top_k=10,
num_ctx=2048,
)
)
self.moderation_prompt = ChatPromptTemplate.from_messages(
[
+80 -66
View File
@@ -1,11 +1,12 @@
import os
import re
import tempfile
from abc import ABC, abstractmethod
from typing import List, Dict, Any, AsyncGenerator, Generator, Optional
from channels.db import database_sync_to_async
from langchain_community.embeddings import OllamaEmbeddings
from langchain_ollama import OllamaEmbeddings
from django.conf import settings
# from langchain_community.llms import Ollama
from langchain_ollama import OllamaLLM
from langchain_community.vectorstores import Chroma
from langchain_core.documents import Document as LangDocument
@@ -23,6 +24,7 @@ from django.core.files.uploadedfile import UploadedFile
from chat_backend.models import Conversation, Prompt, DocumentWorkspace, Document
from pathlib import Path
from chat_backend.services.base_service import BaseService
from chat_backend.ollama_config import ollama_embeddings_kwargs
@database_sync_to_async
@@ -45,7 +47,7 @@ class RAGService(BaseService):
return cls._instance
def __init__(self):
self.embedding_model = OllamaEmbeddings(model="llama3.2" if not settings.DEBUG else "gpt-oss:20b")
self.embedding_model = OllamaEmbeddings(**ollama_embeddings_kwargs())
super().__init__()
self.text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, chunk_overlap=200
@@ -63,7 +65,10 @@ class RAGService(BaseService):
def _initialize_vector_store(self) -> Chroma:
"""Initialize and return the Chroma vector store."""
persist_directory = f"./chroma_db/"
persist_directory = getattr(
settings, "CHROMA_PERSIST_DIRECTORY", "./chroma_db/"
)
os.makedirs(persist_directory, exist_ok=True)
vector_store = Chroma(
embedding_function=self.embedding_model, persist_directory=persist_directory
)
@@ -74,19 +79,54 @@ class RAGService(BaseService):
self.vector_store.delete_collection()
self.vector_store = self._initialize_vector_store()
def _materialize_file_field(self, file_field) -> str:
"""
Write DB-backed FileField bytes to a NamedTemporaryFile for loaders
that require a filesystem path. Caller must os.unlink the path.
"""
suffix = Path(file_field.name).suffix
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
try:
file_field.open("rb")
try:
while True:
chunk = file_field.read(1024 * 1024)
if not chunk:
break
tmp.write(chunk)
finally:
file_field.close()
tmp.close()
return tmp.name
except Exception:
tmp.close()
if os.path.exists(tmp.name):
os.unlink(tmp.name)
raise
def _prepare_documents(self, documents: List[Document]) -> List[Document]:
"""Process documents for ingestion into vector store."""
docs = []
for doc in documents:
print(f"Processing: {doc.file.name}")
loader_class = self._get_file_loader(doc.file.name)
loader = loader_class(doc.file)
chunks = self._load_and_split_documents(doc.file.path)
tmp_path = self._materialize_file_field(doc.file)
try:
chunks = self._load_and_split_documents(
tmp_path,
metadata={
"source": doc.file.name,
"workspace_id": doc.workspace_id,
"document_id": doc.id,
},
)
if chunks:
self.vector_store.add_documents(chunks)
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
self.vector_store.persist()
return docs
def ingest_documents(self, workspace: DocumentWorkspace | None = None) -> None:
"""Ingest documents from a workspace into the vector store."""
@@ -99,18 +139,6 @@ class RAGService(BaseService):
print(f"Processing the documents : {documents}")
self._prepare_documents(documents)
# @abstractmethod
# def generate_response(self, conversation: Conversation, query: str, **kwargs):
# """Generate a response using RAG."""
# pass
# @abstractmethod
# def search_documents(
# self, query: str, workspace: Optional[DocumentWorkspace] = None, k: int = 4
# ) -> List[Document]:
# """Search relevant documents from the vector store."""
# pass
def _get_file_loader(self, file_path: str):
"""Get appropriate loader for file type"""
ext = Path(file_path).suffix.lower()
@@ -120,18 +148,6 @@ class RAGService(BaseService):
"""Sanitize filename for safe storage"""
return re.sub(r"[^\w\-_. ]", "_", filename)
def _save_uploaded_file(self, uploaded_file: UploadedFile, save_dir: str) -> str:
"""Save uploaded file to disk"""
os.makedirs(save_dir, exist_ok=True)
sanitized_name = self._sanitize_filename(uploaded_file.name)
file_path = os.path.join(save_dir, sanitized_name)
with open(file_path, "wb+") as destination:
for chunk in uploaded_file.chunks():
destination.write(chunk)
return file_path
def _load_and_split_documents(
self, file_path: str, metadata: dict = None
) -> List[Document]:
@@ -148,46 +164,47 @@ class RAGService(BaseService):
def add_files_to_store(
self,
file_tupls: List[UploadedFile], # (file_path, name,workspace_id)
file_tupls: List, # (file_path_or_field, name, workspace_id)
workspace_id: str,
source: str = "upload",
save_dir: str = "data/uploads",
) -> Dict[str, Any]:
"""
Process and add uploaded files to vector store
Process and add files to vector store.
Args:
files: List of Django UploadedFile objects
workspace_id: ID of the workspace these belong to
source: Source identifier for documents
save_dir: Directory to save uploaded files
Returns:
Dictionary with processing results
file_tupls entries: (path_str | Django FileField, original_name, workspace_id)
Paths may be temp files; FileFields are materialized from DB storage.
"""
results = {"total_added": 0, "failed_files": [], "processed_files": []}
for file_tuple in file_tupls:
tmp_created = None
try:
# Save file to disk
file_ref, original_name, ws_id = (
file_tuple[0],
file_tuple[1],
file_tuple[2],
)
if isinstance(file_ref, str):
file_path = file_ref
else:
tmp_created = self._materialize_file_field(file_ref)
file_path = tmp_created
# Prepare metadata
metadata = {
"source": file_tuple[1],
"workspace_id": file_tuple[2],
"original_filename": file_tuple[1],
"file_path": file_tuple[0],
"source": original_name,
"workspace_id": ws_id,
"original_filename": original_name,
"file_path": original_name,
}
# Load and split documents
docs = self._load_and_split_documents(file_path, metadata)
# Add to vector store
if docs:
self.vector_store.add_documents(docs)
results["total_added"] += len(docs)
results["processed_files"].append(
{"filename": file_tuple[1], "document_count": len(docs)}
{"filename": original_name, "document_count": len(docs)}
)
except Exception as e:
@@ -195,8 +212,10 @@ class RAGService(BaseService):
{"filename": file_tuple[1], "error": str(e)}
)
continue
finally:
if tmp_created and os.path.exists(tmp_created):
os.unlink(tmp_created)
# Persist changes
self.vector_store.persist()
return results
@@ -245,7 +264,6 @@ class SyncRAGService(RAGService):
query = input_dict["query"]
conversation = input_dict["conversation"]
# You could enhance this to consider historical context in retrieval
relevant_docs = self.search_documents(query, conversation.workspace)
if not relevant_docs:
print("didn't find any relevant docs")
@@ -260,10 +278,11 @@ class SyncRAGService(RAGService):
filter_dict = {}
if workspace:
filter_dict["workspace_id"] = workspace.id
search_kwargs = {"k": k, "filter": filter_dict if filter_dict else None}
print(f"search_kwargs: {search_kwargs}")
retriever = self.vector_store.as_retriever(
search_type="similarity",
search_kwargs={"k": k, "filter": filter_dict if filter_dict else None},
search_kwargs=search_kwargs,
)
return retriever.get_relevant_documents(query)
@@ -299,7 +318,7 @@ class AsyncRAGService(RAGService):
self.rag_chain = (
{
"context": self._retriever_with_history,
"history": lambda x: x['recent_conversation'], #self._format_history(x["conversation"]),
"history": lambda x: x["recent_conversation"],
"question": lambda x: x["query"],
}
| self.prompt
@@ -309,16 +328,12 @@ class AsyncRAGService(RAGService):
async def _format_history(self, conversation: Conversation) -> str:
"""Format conversation history for the prompt."""
# prompts = (
# await Prompt.objects.filter(conversation=conversation)
# .order_by("created_at")
# .alist()
# )
# print(f"prompts that we are seeding with are: {prompts}")
# return "\n".join(
# f"{'User' if prompt.is_user else 'AI'}: {prompt.text}" for prompt in prompts
# )
return "\n".join([f"{"User" if prompt.type=="human" else "AI"}: {prompt.text()}" for prompt in conversation])
return "\n".join(
[
f'{"User" if prompt.type == "human" else "AI"}: {prompt.text()}'
for prompt in conversation
]
)
async def _retriever_with_history(self, input_dict: Dict[str, Any]) -> str:
"""Retrieve documents considering conversation history."""
@@ -327,7 +342,6 @@ class AsyncRAGService(RAGService):
conversation = input_dict["conversation"]
workspace = input_dict["workspace"]
# You could enhance this to consider historical context in retrieval
docs = await self.search_documents(query, workspace)
if not docs:
+17 -236
View File
@@ -1,251 +1,32 @@
import os
from unittest import TestCase, mock
from unittest.mock import MagicMock, patch, AsyncMock
from typing import List, Dict, Any
import unittest
from unittest import TestCase
from django.test import TestCase as DjangoTestCase
from chat_backend.services.rag_services import (
RAGService,
SyncRAGService,
AsyncRAGService,
from chat_backend.services.prompt_classifier.prompt_classifier import (
PromptClassifier,
PromptType,
)
from chat_backend.models import Conversation, Prompt, DocumentWorkspace, Document
from chat_backend.services.prompt_classifier import PromptClassifier, PromptType
from parameterized import parameterized
# class TestRAGService(TestCase):
# def setUp(self):
# self.rag_service = RAGService()
# self.rag_service.vector_store = MagicMock()
# self.rag_service.embedding_model = MagicMock()
# self.rag_service.text_splitter = MagicMock()
# def test_initialize_vector_store(self):
# with patch("os.path.exists", return_value=False), patch(
# "os.makedirs"
# ) as mock_makedirs, patch(
# "langchain_community.vectorstores.Chroma"
# ) as mock_chroma:
# # Reset the vector store to test initialization
# self.rag_service.vector_store = None
# result = self.rag_service._initialize_vector_store()
# mock_makedirs.assert_called_once_with("chroma_db")
# mock_chroma.assert_called_once_with(
# embedding_function=self.rag_service.embedding_model,
# persist_directory="chroma_db",
# )
# self.assertIsNotNone(result)
# def test_prepare_documents(self):
# mock_doc1 = MagicMock(spec=Document)
# mock_doc1.content = "Test content"
# mock_doc1.source = "test_source"
# mock_doc1.workspace = MagicMock()
# mock_doc1.workspace.id = 1
# mock_doc1.id = 1
# self.rag_service.text_splitter.split_text.return_value = ["chunk1", "chunk2"]
# result = self.rag_service._prepare_documents([mock_doc1])
# self.assertEqual(len(result), 2)
# self.rag_service.text_splitter.split_text.assert_called_once_with(
# "Test content"
# )
# self.assertEqual(result[0].page_content, "chunk1")
# self.assertEqual(result[0].metadata["source"], "test_source")
# def test_ingest_documents(self):
# mock_workspace = MagicMock()
# mock_document = MagicMock()
# mock_documents = [mock_document]
# with patch(
# "services.rag_services.Document.objects.filter", return_value=mock_documents
# ):
# self.rag_service._prepare_documents = MagicMock(
# return_value=["processed_doc"]
# )
# self.rag_service.ingest_documents(mock_workspace)
# self.rag_service.vector_store.add_documents.assert_called_once_with(
# ["processed_doc"]
# )
# self.rag_service.vector_store.persist.assert_called_once()
# class TestSyncRAGService(DjangoTestCase):
# def setUp(self):
# self.sync_service = SyncRAGService()
# self.sync_service.vector_store = MagicMock()
# self.sync_service.llm = MagicMock()
# self.sync_service.rag_chain = MagicMock()
# self.mock_conversation = MagicMock(spec=Conversation)
# self.mock_conversation.workspace = MagicMock()
# self.mock_prompt1 = MagicMock(spec=Prompt)
# self.mock_prompt1.is_user = True
# self.mock_prompt1.text = "User question"
# self.mock_prompt1.created_at = "2023-01-01"
# self.mock_prompt2 = MagicMock(spec=Prompt)
# self.mock_prompt2.is_user = False
# self.mock_prompt2.text = "AI response"
# self.mock_prompt2.created_at = "2023-01-02"
# def test_format_history(self):
# with patch("services.rag_services.Prompt.objects.filter") as mock_filter:
# mock_filter.return_value.order_by.return_value = [
# self.mock_prompt1,
# self.mock_prompt2,
# ]
# result = self.sync_service._format_history(self.mock_conversation)
# expected = "User: User question\nAI: AI response"
# self.assertEqual(result, expected)
# mock_filter.assert_called_once_with(conversation=self.mock_conversation)
# def test_retriever_with_history(self):
# input_dict = {"query": "test query", "conversation": self.mock_conversation}
# self.sync_service.search_documents = MagicMock(return_value=["doc1", "doc2"])
# result = self.sync_service._retriever_with_history(input_dict)
# self.sync_service.search_documents.assert_called_once_with(
# "test query", self.mock_conversation.workspace
# )
# self.assertEqual(result, ["doc1", "doc2"])
# def test_search_documents(self):
# mock_retriever = MagicMock()
# mock_retriever.get_relevant_documents.return_value = ["doc1", "doc2"]
# self.sync_service.vector_store.as_retriever.return_value = mock_retriever
# result = self.sync_service.search_documents(
# "test query", self.mock_conversation.workspace
# )
# self.sync_service.vector_store.as_retriever.assert_called_once_with(
# search_type="similarity",
# search_kwargs={
# "k": 4,
# "filter": {"workspace_id": self.mock_conversation.workspace.id},
# },
# )
# self.assertEqual(result, ["doc1", "doc2"])
# def test_generate_response(self):
# chain_input = {"query": "test query", "conversation": self.mock_conversation}
# mock_stream = ["chunk1", "chunk2", "chunk3"]
# self.sync_service.rag_chain.stream.return_value = mock_stream
# result = list(
# self.sync_service.generate_response(self.mock_conversation, "test query")
# )
# self.sync_service.rag_chain.stream.assert_called_once_with(chain_input)
# self.assertEqual(result, mock_stream)
# class TestAsyncRAGService(DjangoTestCase):
# def setUp(self):
# self.async_service = AsyncRAGService()
# self.async_service.vector_store = MagicMock()
# self.async_service.llm = MagicMock()
# self.async_service.rag_chain = AsyncMock()
# self.mock_conversation = MagicMock(spec=Conversation)
# self.mock_conversation.workspace = MagicMock()
# self.mock_prompt1 = MagicMock(spec=Prompt)
# self.mock_prompt1.is_user = True
# self.mock_prompt1.text = "User question"
# self.mock_prompt1.created_at = "2023-01-01"
# self.mock_prompt2 = MagicMock(spec=Prompt)
# self.mock_prompt2.is_user = False
# self.mock_prompt2.text = "AI response"
# self.mock_prompt2.created_at = "2023-01-02"
# async def test_format_history(self):
# mock_manager = AsyncMock()
# mock_manager.order_by.return_value.alist.return_value = [
# self.mock_prompt1,
# self.mock_prompt2,
# ]
# with patch(
# "services.rag_services.Prompt.objects.filter", return_value=mock_manager
# ):
# result = await self.async_service._format_history(self.mock_conversation)
# expected = "User: User question\nAI: AI response"
# self.assertEqual(result, expected)
# mock_manager.order_by.assert_called_once_with("created_at")
# async def test_retriever_with_history(self):
# input_dict = {"query": "test query", "conversation": self.mock_conversation}
# self.async_service.search_documents = AsyncMock(return_value=["doc1", "doc2"])
# result = await self.async_service._retriever_with_history(input_dict)
# self.async_service.search_documents.assert_awaited_once_with(
# "test query", self.mock_conversation.workspace
# )
# self.assertEqual(result, ["doc1", "doc2"])
# async def test_search_documents(self):
# mock_retriever = AsyncMock()
# mock_retriever.aget_relevant_documents.return_value = ["doc1", "doc2"]
# self.async_service.vector_store.as_retriever.return_value = mock_retriever
# result = await self.async_service.search_documents(
# "test query", self.mock_conversation.workspace
# )
# self.async_service.vector_store.as_retriever.assert_called_once_with(
# search_type="similarity",
# search_kwargs={
# "k": 4,
# "filter": {"workspace_id": self.mock_conversation.workspace.id},
# },
# )
# self.assertEqual(result, ["doc1", "doc2"])
# async def test_generate_response(self):
# chain_input = {"query": "test query", "conversation": self.mock_conversation}
# mock_stream = ["chunk1", "chunk2", "chunk3"]
# self.async_service.rag_chain.astream.return_value = mock_stream
# chunks = []
# async for chunk in self.async_service.generate_response(
# self.mock_conversation, "test query"
# ):
# chunks.append(chunk)
# self.async_service.rag_chain.astream.assert_awaited_once_with(chain_input)
# self.assertEqual(chunks, mock_stream)
@unittest.skipIf(
os.environ.get("SKIP_RAG_INIT", "").lower() in {"1", "true", "yes"},
"Requires live Ollama; skipped when SKIP_RAG_INIT is set",
)
class PromptClassifierTestCase(TestCase):
def setUp(self):
self.service = PromptClassifier()
@parameterized.expand([
@parameterized.expand(
[
["Tell me a joke", PromptType.GENERAL_CHAT],
["Create an image of a dog for me", PromptType.IMAGE_GENERATION],
["highlight the features of the backyard playset if they were to choose us and make the language more long form",PromptType.GENERAL_CHAT],
])
[
"highlight the features of the backyard playset if they were to choose us and make the language more long form",
PromptType.GENERAL_CHAT,
],
]
)
def test_prompt_classification(self, prompt, expected_output):
result = self.service.classify(prompt)
self.assertEqual(result, expected_output)
@@ -3,6 +3,7 @@ from langchain_core.prompts import ChatPromptTemplate
# from langchain_community.llms import Ollama
from langchain_ollama import OllamaLLM
from typing import Optional
from chat_backend.ollama_config import ollama_llm_kwargs
class TitleGenerator:
@@ -12,11 +13,12 @@ class TitleGenerator:
def __init__(self):
self.llm = OllamaLLM(
model="llama3.2",
**ollama_llm_kwargs(
temperature=0.5, # Slightly creative but not too random
top_k=20,
num_ctx=2048, # Shorter context needed for titles
)
)
self.title_prompt = ChatPromptTemplate.from_messages(
[
+22 -2
View File
@@ -1,20 +1,40 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.conf import settings
import os
from chat_backend.models import Document
from .services.rag_services import AsyncRAGService
def _rag_init_skipped() -> bool:
return os.environ.get("SKIP_RAG_INIT", "").lower() in {"1", "true", "yes"}
@receiver(post_save, sender=Document)
def update_vector_on_save(sender, instance, **kwargs):
"""Update vector store when documents are saved"""
if _rag_init_skipped():
return
if not kwargs.get("created", False):
return
try:
from .services.rag_services import AsyncRAGService
if kwargs.get("created", False):
rag_service = AsyncRAGService()
rag_service.ingest_documents()
except Exception as exc:
print(f"Skipping vector update on Document save: {exc}")
@receiver(post_delete, sender=Document)
def delete_vector_on_remove(sender, instance, **kwargs):
"""Handle document deletion by re-indexing the whole workspace"""
if _rag_init_skipped():
return
try:
from .services.rag_services import AsyncRAGService
rag_service = AsyncRAGService()
rag_service.ingest_documents()
except Exception as exc:
print(f"Skipping vector update on Document delete: {exc}")
+91
View File
@@ -0,0 +1,91 @@
"""Store FileField contents in Postgres (BinaryField), not on the container filesystem."""
from __future__ import annotations
import mimetypes
from io import BytesIO
from django.core.files.base import ContentFile
from django.core.files.storage import Storage
from django.db import transaction
from django.utils.deconstruct import deconstructible
@deconstructible
class DatabaseStorage(Storage):
"""Django storage backend backed by chat_backend.StoredFile rows."""
def _model(self):
from chat_backend.models import StoredFile
return StoredFile
def _open(self, name, mode="rb"):
stored = self._model().objects.get(name=name)
return ContentFile(bytes(stored.content), name=name)
def _save(self, name, content):
name = self.get_available_name(name)
if hasattr(content, "chunks"):
data = b"".join(chunk for chunk in content.chunks())
else:
data = content.read()
if isinstance(data, str):
data = data.encode("utf-8")
content_type = getattr(content, "content_type", None) or mimetypes.guess_type(name)[0]
StoredFile = self._model()
with transaction.atomic():
StoredFile.objects.update_or_create(
name=name,
defaults={
"content": data,
"size": len(data),
"content_type": content_type or "",
},
)
return name
def delete(self, name):
self._model().objects.filter(name=name).delete()
def exists(self, name):
return self._model().objects.filter(name=name).exists()
def listdir(self, path):
prefix = path.rstrip("/")
if prefix:
prefix = f"{prefix}/"
names = self._model().objects.filter(name__startswith=prefix).values_list(
"name", flat=True
)
dirs: set[str] = set()
files: list[str] = []
for full in names:
rest = full[len(prefix) :] if prefix else full
if "/" in rest:
dirs.add(rest.split("/", 1)[0])
elif rest:
files.append(rest)
return list(dirs), files
def size(self, name):
return self._model().objects.values_list("size", flat=True).get(name=name)
def url(self, name):
# Files live in DB; serve via authenticated API / serializer when needed.
return f"/api/stored-files/{name}"
def path(self, name):
raise NotImplementedError(
"DatabaseStorage has no filesystem path; use .open()/.read() or a temp file."
)
def get_accessed_time(self, name):
raise NotImplementedError("DatabaseStorage does not track accessed time.")
def get_created_time(self, name):
return self._model().objects.values_list("created", flat=True).get(name=name)
def get_modified_time(self, name):
return self._model().objects.values_list("last_modified", flat=True).get(name=name)
+6 -6
View File
@@ -58,7 +58,7 @@ from django.core.files.base import ContentFile
import math
import datetime
import pytz
from langchain_community.embeddings import OllamaEmbeddings
from langchain_ollama import OllamaEmbeddings
from dateutil.relativedelta import relativedelta
from django.views.decorators.csrf import csrf_exempt
@@ -70,6 +70,7 @@ from .services.title_generator import title_generator
from .services.moderation_classifier import moderation_classifier, ModerationLabel
from .services.prompt_classifier.prompt_classifier import PromptClassifier, PromptType
from .services.data_analysis_service import AsyncDataAnalysisService
from .ollama_config import ollama_llm_kwargs, ollama_model
@@ -83,7 +84,7 @@ logger = logging.getLogger(__name__)
CHANNEL_NAME: str = "llm_messages"
MODEL_NAME: str = "llama3.2"
MODEL_NAME: str = ollama_model()
# Create your views here.
class CustomObtainTokenView(TokenObtainPairView):
@@ -717,7 +718,7 @@ prompt = ChatPromptTemplate.from_messages(
[("system", "You are a helpful assistant."), ("user", "{input}")]
)
llm = OllamaLLM(model=MODEL_NAME)
llm = OllamaLLM(**ollama_llm_kwargs(model=MODEL_NAME))
# output_parser = StrOutputParser()
# # Chain
@@ -790,14 +791,13 @@ class DocumentUploadView(APIView):
return Response(serializer.data, status=status.HTTP_201_CREATED)
def process_document(self, document):
file_path = os.path.join(settings.MEDIA_ROOT, document.file.name)
# File bytes live in DB (DatabaseStorage); RAG materializes a temp path.
document.processed = True
document.active = True
document.save()
service = AsyncRAGService()
service.add_files_to_store(
[(file_path, document.file.name, document.workspace_id)],
[(document.file, document.file.name, document.workspace_id)],
workspace_id=document.workspace_id,
)
+149 -135
View File
@@ -1,46 +1,136 @@
"""
Django settings for llm_be project.
Generated by 'django-admin startproject' using Django 3.2.18.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.2/ref/settings/
Environment-driven for local/dev/beta/prod (match company_site / scha).
"""
from pathlib import Path
from datetime import timedelta
from urllib.parse import urlparse
import json
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/3.2/howto/deployment/checklist/
def env(key: str, default: str | None = None) -> str | None:
return os.environ.get(key, default)
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-6suk6fj5q2)1tj%)f(wgw1smnliv5-#&@zvgvj1wp#(#@h#31x"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
def env_bool(key: str, default: bool = False) -> bool:
value = os.environ.get(key)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
def env_list(key: str, default: str = "") -> list[str]:
value = os.environ.get(key, default)
if not value:
return []
value = value.strip()
if value.startswith("["):
try:
parsed = json.loads(value)
except ValueError:
parsed = None
if isinstance(parsed, list):
return [str(item).strip() for item in parsed if str(item).strip()]
return [item.strip() for item in value.split(",") if item.strip()]
def database_config() -> dict:
database_url = env("DATABASE_URL")
if database_url:
parsed = urlparse(database_url)
return {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": parsed.path.lstrip("/"),
"USER": parsed.username or "",
"PASSWORD": parsed.password or "",
"HOST": parsed.hostname or "",
"PORT": str(parsed.port or 5432),
}
}
if env("DB_HOST"):
return {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": env("DB_NAME", "chat_backend"),
"USER": env("DB_USER", "chat_backend"),
"PASSWORD": env("DB_PASSWORD", ""),
"HOST": env("DB_HOST"),
"PORT": env("DB_PORT", "5432"),
}
}
return {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
def build_csrf_trusted_origins(
allowed_hosts: list[str], explicit: list[str] | None = None
) -> list[str]:
if explicit:
return explicit
local_hosts = {"localhost", "127.0.0.1", "0.0.0.0"}
origins: list[str] = []
for host in allowed_hosts:
if not host or host == "*" or host.startswith("."):
continue
hostname = host.split(":")[0]
scheme = "http" if hostname in local_hosts else "https"
origins.append(f"{scheme}://{host}")
return origins
DJANGO_ENV = (env("DJANGO_ENV", "dev") or "dev").lower()
SECRET_KEY = env(
"DJANGO_SECRET_KEY",
"django-insecure-dev-only-change-me-before-production",
)
DEBUG = env_bool("DJANGO_DEBUG", DJANGO_ENV == "dev")
allowed_hosts = env_list(
"DJANGO_ALLOWED_HOSTS",
"localhost,127.0.0.1,0.0.0.0,chatbackend.aimloperations.com,chat.aimloperations.com",
)
ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"]
CSRF_TRUSTED_ORIGINS = build_csrf_trusted_origins(
ALLOWED_HOSTS,
env_list("DJANGO_CSRF_TRUSTED_ORIGINS"),
)
CORS_ALLOW_CREDENTIALS = False
ALLOWED_HOSTS = [
"*.aimloperations.com",
"localhost",
"127.0.0.1",
"localhost:3000",
"127.0.0.1:3000",
"chat.aimloperations.com",
"chatbackend.aimloperations.com",
]
CORS_ORIGIN_ALLOW_ALL = True
CSRF_TRUSTED_ORIGINS = ["http://localhost", "http://127.0.0.1", "http://localhost:3000"]
CORS_ORIGIN_ALLOW_ALL = env_bool("CORS_ORIGIN_ALLOW_ALL", True)
CORS_ALLOWED_ORIGINS = env_list(
"CORS_ALLOWED_ORIGINS",
"http://localhost:3000,http://127.0.0.1:3000,https://chat.aimloperations.com",
)
# Ollama — GPU host on LAN for deployed envs; loopback for local Ollama.
# Prod/beta control-node secret should set OLLAMA_BASE_URL=http://10.0.0.128:11434
OLLAMA_BASE_URL = env("OLLAMA_BASE_URL", "http://127.0.0.1:11434") or "http://127.0.0.1:11434"
OLLAMA_MODEL = env(
"OLLAMA_MODEL",
"llama3.2" if not DEBUG else "gpt-oss:20b",
) or ("llama3.2" if not DEBUG else "gpt-oss:20b")
OLLAMA_EMBED_MODEL = env("OLLAMA_EMBED_MODEL", OLLAMA_MODEL) or OLLAMA_MODEL
# Application definition
CHROMA_PERSIST_DIRECTORY = env(
"CHROMA_PERSIST_DIRECTORY",
str(BASE_DIR / "chroma_db"),
) or str(BASE_DIR / "chroma_db")
INSTALLED_APPS = [
"daphne",
@@ -49,6 +139,7 @@ INSTALLED_APPS = [
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"whitenoise.runserver_nostatic",
"django.contrib.staticfiles",
"chat_backend",
"rest_framework",
@@ -58,23 +149,18 @@ INSTALLED_APPS = [
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
]
ROOT_URLCONF = "llm_be.urls"
# SETTINGS_PATH = os.path.dirname(os.path.dirname(__file__))
# TEMPLATE_DIRS = (
# os.path.join(SETTINGS_PATH, 'templates'),
# )
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
@@ -94,20 +180,7 @@ TEMPLATES = [
WSGI_APPLICATION = "llm_be.wsgi.application"
ASGI_APPLICATION = "llm_be.asgi.application"
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
DATABASES = database_config()
AUTH_PASSWORD_VALIDATORS = [
{
@@ -124,40 +197,35 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.2/howto/static-files/
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
STORAGES = {
"default": {
"BACKEND": "chat_backend.storage.DatabaseStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# custom user model
AUTH_USER_MODEL = "chat_backend.CustomUser"
# rest framework jwt stuff
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication",
), #
),
}
SIMPLE_JWT = {
@@ -175,85 +243,31 @@ SIMPLE_JWT = {
"TOKEN_TYPE_CLAIM": "token_type",
}
# CORS settings
CORS_ALLOWED_ORIGINS = [
"http://localhost:3000",
"http://127.0.0.1:3000",
]
# channel settings
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer",
},
}
# # Office 365 settings
# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
# EMAIL_HOST = os.getenv("APP_EMAIL_HOST", "smtp.office365.com")
# EMAIL_PORT = os.getenv("APP_EMAIL_PORT", 587)
# EMAIL_HOST_USER = "ryan@aimloperations.com"#os.getenv("APP_EMAIL_HOST_USER")
# SERVER_EMAIL = EMAIL_HOST_USER
# DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
# EMAIL_HOST_PASSWORD = "!HopeThisW0rkz"#os.getenv("APP_EMAIL_HOST_PASSWORD")
# EMAIL_USE_TLS = os.getenv("APP_EMAIL_USE_TLS", True)
# EMAIL_TIMEOUT = os.getenv("APP_EMAIL_TIMEOUT", 60)
EMAIL_HOST = env("EMAIL_HOST", "mail.smtp2go.com") or "mail.smtp2go.com"
EMAIL_HOST_USER = env("EMAIL_HOST_USER", "") or ""
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", "") or ""
EMAIL_PORT = int(env("EMAIL_PORT", "2525") or "2525")
EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True)
# SMTP2GO
EMAIL_HOST = "mail.smtp2go.com"
EMAIL_HOST_USER = "info.aimloperations.com"
EMAIL_HOST_PASSWORD = "ZDErIII2sipNNVMz"
EMAIL_PORT = 2525
EMAIL_USE_TLS = True
CAPTCHA_SECRET_KEY = env("CAPTCHA_SECRET_KEY", "") or ""
# Captcha
CAPTCHA_SECRET_KEY = "6LfENu4qAAAAABdrj6JTviq-LfdPP5imhE-Os7h9"
directory_path = 'logs'
# LOGGING = {
# 'version': 1,
# 'disable_existing_loggers': False,
# 'formatters': {
# 'verbose': {
# 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
# 'style': '{',
# },
# 'simple': {
# 'format': '{levelname} {message}',
# 'style': '{',
# },
# },
# 'handlers': {
# 'console': {
# 'level': 'INFO',
# 'class': 'logging.StreamHandler',
# 'formatter': 'simple',
# },
# 'file': {
# 'level': 'DEBUG',
# 'class': 'logging.handlers.RotatingFileHandler',
# 'filename': f'{directory_path}/django.log',
# 'maxBytes': 1024 * 1024 * 5, # 5 MB
# 'backupCount': 5,
# 'formatter': 'verbose',
# },
# },
# 'loggers': {
# 'django': {
# 'handlers': ['console', 'file'],
# 'level': 'INFO',
# 'propagate': True,
# },
# 'my_app': {
# 'handlers': ['console', 'file'],
# 'level': 'DEBUG',
# 'propagate': False,
# },
# },
# }
USE_TLS_PROXY = env_bool("USE_TLS_PROXY", DJANGO_ENV in {"prod", "beta"})
if USE_TLS_PROXY:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
directory_path = env("DJANGO_LOG_DIR", str(BASE_DIR / "logs")) or str(BASE_DIR / "logs")
os.makedirs(directory_path, exist_ok=True)
# Feature Flags
ALLOW_IMAGE_GENERATION = False
ALLOW_INTERNET_ACCESS = True
ALLOW_IMAGE_GENERATION = env_bool("ALLOW_IMAGE_GENERATION", False)
ALLOW_INTERNET_ACCESS = env_bool("ALLOW_INTERNET_ACCESS", True)
if DJANGO_ENV in {"prod", "beta"} and (
not SECRET_KEY or "dev-only" in SECRET_KEY or SECRET_KEY.startswith("django-insecure")
):
raise ValueError("DJANGO_SECRET_KEY must be set to a real secret in prod/beta.")
+52
View File
@@ -0,0 +1,52 @@
[project]
name = "chat-backend"
version = "0.1.0"
description = "Django chat backend for AIML Operations"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"Django==6.0",
"channels==4.3.2",
"daphne==4.2.1",
"uvicorn==0.38.0",
"gunicorn==23.0.0",
"psycopg2-binary==2.9.10",
"whitenoise==6.9.0",
"djangorestframework==3.16.1",
"djangorestframework-simplejwt==5.5.1",
"django-cors-headers==4.9.0",
"django-autoslug==1.9.9",
"django-filter==25.2",
"ollama==0.6.1",
"langchain==1.1.2",
"langchain-core==1.1.1",
"langchain-community==0.4.1",
"langchain-classic==1.0.0",
"langchain-ollama==1.0.0",
"langchain-chroma==1.0.0",
"langchain-text-splitters==1.0.0",
"chromadb==1.3.5",
"unstructured==0.18.21",
"pypdf==6.4.0",
"python-docx==1.2.0",
"docx2txt==0.8",
"openpyxl==3.1.5",
"pandas==2.3.3",
"numpy==2.2.6",
"matplotlib==3.10.7",
"pillow==12.0.0",
"beautifulsoup4==4.14.3",
"ddgs==9.9.3",
"httpx==0.28.1",
"python-dateutil==2.9.0.post0",
"pytz==2025.2",
]
[dependency-groups]
dev = [
"parameterized==0.9.0",
"black==25.11.0",
]
[tool.uv]
package = false
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
cd /app/llm_be
wait_for_database() {
if [[ -z "${DATABASE_URL:-}" && -z "${DB_HOST:-}" ]]; then
return 0
fi
echo "Waiting for database..."
for _ in $(seq 1 30); do
if SKIP_RAG_INIT=1 uv run python - <<'PY'
import os
import sys
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "llm_be.settings")
os.environ["SKIP_RAG_INIT"] = "1"
import django
from django.db import connections
from django.db.utils import OperationalError
django.setup()
try:
connections["default"].ensure_connection()
except OperationalError:
sys.exit(1)
PY
then
echo "Database is ready."
return 0
fi
sleep 2
done
echo "Database did not become ready in time." >&2
exit 1
}
wait_for_database
# ASGI (HTTP + WebSockets). UvicornWorker required for channels.
export SKIP_RAG_INIT=1
uv run python manage.py migrate --noinput
uv run python manage.py collectstatic --noinput
unset SKIP_RAG_INIT
exec uv run gunicorn llm_be.asgi:application \
-k uvicorn.workers.UvicornWorker \
--bind "${GUNICORN_BIND:-0.0.0.0:8000}" \
--workers "${GUNICORN_WORKERS:-2}"
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="${1:?Usage: $0 <env-file>}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Environment file not found: $ENV_FILE" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
DJANGO_ENV="${DJANGO_ENV:-prod}"
required_vars=(
DJANGO_SECRET_KEY
DJANGO_ALLOWED_HOSTS
DATABASE_URL
WEB_PORT
OLLAMA_BASE_URL
)
if [[ "$DJANGO_ENV" == "prod" || "$DJANGO_ENV" == "beta" ]]; then
required_vars+=(
EMAIL_HOST_USER
EMAIL_HOST_PASSWORD
)
fi
missing=()
for var in "${required_vars[@]}"; do
if [[ -z "${!var:-}" ]]; then
missing+=("$var")
fi
done
if ((${#missing[@]} > 0)); then
echo "Missing required environment variables in $ENV_FILE:" >&2
printf ' - %s\n' "${missing[@]}" >&2
echo "Copy .env.prod.example to ~/Documents/secrets/chat_backend/chat_backend_${DJANGO_ENV}.env and set production values." >&2
exit 1
fi
if [[ "$DJANGO_ENV" == "prod" && ( "$DJANGO_SECRET_KEY" == change-me* || "$DJANGO_SECRET_KEY" == *dev-only* ) ]]; then
echo "DJANGO_SECRET_KEY must be changed from the example value for production." >&2
exit 1
fi
echo "Environment validation passed (DJANGO_ENV=$DJANGO_ENV)."
Generated
+4898
View File
File diff suppressed because it is too large Load Diff