Dockerize chat_backend + Ollama LAN + DB file storage (#6) (#7)
Unit Tests / test (push) Successful in 13s
Unit Tests / test (push) Successful in 13s
## Summary Implements [chat_backend#6](#6) Part A: - **uv** packaging (`pyproject.toml` + `uv.lock`), Docker/compose (dev + prod), entrypoint/validate-env, Gitea unit-test + auto-deploy workflows (mirror `scha`) - Env-driven Django settings (`DJANGO_*`, `DATABASE_URL`, CSRF/CORS) - **`OLLAMA_BASE_URL`** wired through all Ollama/LangChain clients (prod → `http://10.0.0.128:11434`) - **DatabaseStorage** — prompt/document file blobs in Postgres (`StoredFile`), not container FS; RAG materializes temp paths for loaders - ASGI via `gunicorn` + `UvicornWorker` (HTTP + WebSockets) Companion server-infra PR registers `app_catalog` / `host_apps` (port **8003**). ## Test plan - [ ] `uv sync && cd llm_be && SKIP_RAG_INIT=1 uv run python manage.py test` - [ ] `docker compose build && docker compose up` against bundled Postgres - [ ] Confirm Ollama calls use `OLLAMA_BASE_URL` (not hardcoded localhost) - [ ] Upload a document / prompt file → row in `chat_backend_storedfile`, no disk under `media/` - [ ] After server-infra merge + secret/Postgres/NPM: deploy via `deploy.sh --app chat_backend --env prod`Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 }}"
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -1,40 +1,151 @@
|
|||||||
# Chat Bot Backend
|
# Chat Backend
|
||||||
|
|
||||||
## Setup
|
Django + Channels API for AIML Operations chat (`chatbackend.aimloperations.com`).
|
||||||
|
Packaging via `uv`; production deploy via `server-infra`.
|
||||||
|
|
||||||
Clone the repo
|
Companion frontend: [`chat_web_app`](https://git.aimloperations.com/ai_ml_operations/chat_web_app)
|
||||||
```console
|
(node-static, not Docker).
|
||||||
git clone http://10.0.0.160:3000/AI_ML_Operations_LLC/Chat_Bot_Backend.git
|
|
||||||
|
Ticket: [chat_backend#6](https://git.aimloperations.com/ai_ml_operations/chat_backend/issues/6)
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
chat_backend/ ← repo root (Dockerfile, compose, pyproject, .gitea)
|
||||||
|
├── llm_be/ ← Django project root (manage.py)
|
||||||
|
│ ├── manage.py
|
||||||
|
│ ├── llm_be/ ← settings, urls, asgi/wsgi
|
||||||
|
│ └── chat_backend/ ← app (models, consumers, services, storage)
|
||||||
|
├── scripts/
|
||||||
|
│ ├── docker-entrypoint.sh
|
||||||
|
│ └── validate-env.sh
|
||||||
|
├── docker-compose.yml ← local/CI (bundled Postgres)
|
||||||
|
└── docker-compose.prod.yml ← server-infra (external DATABASE_URL)
|
||||||
```
|
```
|
||||||
|
|
||||||
Go into the repo
|
## Local development
|
||||||
```console
|
|
||||||
cd Chat_Bot_Backend
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- [uv](https://docs.astral.sh/uv/)
|
||||||
|
- Docker + Docker Compose (optional, recommended)
|
||||||
|
- Ollama reachable at `OLLAMA_BASE_URL` for LLM features
|
||||||
|
|
||||||
|
### uv (host)
|
||||||
|
|
||||||
|
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
Create the virtual environment
|
Without `DATABASE_URL` / `DB_HOST`, settings fall back to SQLite (`llm_be/db.sqlite3`).
|
||||||
```console
|
|
||||||
virtualenv --python=python3.9 venv
|
Tests (skip live-Ollama classifier cases):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd llm_be
|
||||||
|
SKIP_RAG_INIT=1 uv run python manage.py test
|
||||||
```
|
```
|
||||||
|
|
||||||
Activate the Virtual Environment
|
### Docker (dev, bundled Postgres)
|
||||||
```console
|
|
||||||
. venv/bin/activate
|
```bash
|
||||||
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Install the requirments
|
App: http://localhost:8003 — Postgres via bundled `db`
|
||||||
```console
|
(`postgres://chat_backend:chat_backend@db:5432/chat_backend`).
|
||||||
python -m pip install -r requirements.txt
|
Compose does **not** read host `DATABASE_URL` (avoids CI/prod leaks); override
|
||||||
|
with `COMPOSE_DATABASE_URL` if needed.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Dev default | Prod required | Notes |
|
||||||
|
|----------|-------------|---------------|-------|
|
||||||
|
| `DJANGO_ENV` | `dev` | `prod` / `beta` | |
|
||||||
|
| `DJANGO_SECRET_KEY` | insecure default | yes | Must be real in prod/beta |
|
||||||
|
| `DJANGO_DEBUG` | true when `dev` | `false` | |
|
||||||
|
| `DJANGO_ALLOWED_HOSTS` | localhost + chat hosts | yes | Comma-separated |
|
||||||
|
| `DJANGO_CSRF_TRUSTED_ORIGINS` | derived from hosts | optional | Full origins |
|
||||||
|
| `DATABASE_URL` | SQLite fallback | yes | Shared Postgres in prod |
|
||||||
|
| `WEB_PORT` | n/a (compose maps 8003) | `8003` | Host port for prod compose |
|
||||||
|
| `OLLAMA_BASE_URL` | `http://127.0.0.1:11434` | yes | GPU host in prod: `http://10.0.0.128:11434` |
|
||||||
|
| `OLLAMA_MODEL` / `OLLAMA_EMBED_MODEL` | from `DEBUG` | optional | Override model names |
|
||||||
|
| `EMAIL_HOST_*` | empty | yes (prod/beta) | SMTP2GO |
|
||||||
|
| `CAPTCHA_SECRET_KEY` | empty | recommended | |
|
||||||
|
| `CORS_ALLOWED_ORIGINS` | local + chat FE | set in prod | Frontend origin |
|
||||||
|
| `USE_TLS_PROXY` | false (dev) | true behind NPM | Sets `SECURE_PROXY_SSL_HEADER` |
|
||||||
|
| `GUNICORN_WORKERS` / `GUNICORN_BIND` | 2 / `0.0.0.0:8000` | optional | Entrypoint |
|
||||||
|
| `SKIP_RAG_INIT` | unset | CI/migrate often `1` | Skip Chroma/Ollama boot work |
|
||||||
|
|
||||||
|
Templates: `.env.example` (local), `.env.prod.example` (control-node secret).
|
||||||
|
|
||||||
|
Control-node secret path (server-infra on ai-server-4080):
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/Documents/secrets/chat_backend/chat_backend_prod.env
|
||||||
```
|
```
|
||||||
|
|
||||||
Pre-populate the database with test data
|
Validate with:
|
||||||
|
|
||||||
Run the dev server
|
```bash
|
||||||
```console
|
./scripts/validate-env.sh ~/Documents/secrets/chat_backend/chat_backend_prod.env
|
||||||
python manage.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## TODO
|
If `DATABASE_URL` password contains `$`, escape each as `$$` for Compose.
|
||||||
|
|
||||||
- [ ] Create inital database with temp data
|
## Ollama
|
||||||
- [ ] Do a lot of stuff.....
|
|
||||||
|
All clients (`ollama.Client`, `OllamaLLM`, `OllamaEmbeddings`, `ChatOllama`) use
|
||||||
|
`OLLAMA_BASE_URL` — never hardcoded localhost in deployed code.
|
||||||
|
|
||||||
|
| Env | Typical URL |
|
||||||
|
|-----|-------------|
|
||||||
|
| Local (Ollama on same machine) | `http://127.0.0.1:11434` |
|
||||||
|
| prod / beta (containers on adama/roslin/ai-server) | `http://10.0.0.128:11434` |
|
||||||
|
|
||||||
|
Firewall / Ollama listen on ai-server-4080 must allow `10.0.0.0/24` → `:11434`.
|
||||||
|
|
||||||
|
## File storage
|
||||||
|
|
||||||
|
Prompt attachments and workspace documents use **`DatabaseStorage`**
|
||||||
|
(`chat_backend.StoredFile` BinaryField in Postgres). Blobs are **not** written
|
||||||
|
to the container filesystem under `media/`.
|
||||||
|
|
||||||
|
RAG loaders that need a path materialize a short-lived temp file, then delete it.
|
||||||
|
Chroma’s vector index may still use a volume (`chroma_db`); that is embeddings
|
||||||
|
metadata, not the original upload.
|
||||||
|
|
||||||
|
## Production (docker-compose.prod.yml)
|
||||||
|
|
||||||
|
- Single `web` service; **no** bundled DB — `DATABASE_URL` → shared Postgres (`10.0.0.230`).
|
||||||
|
- Host port from `WEB_PORT` (catalog: **8003**; beta reserved **8013**).
|
||||||
|
- Entrypoint: wait DB → migrate → collectstatic → `gunicorn` + `UvicornWorker`
|
||||||
|
(ASGI for HTTP **and** WebSockets).
|
||||||
|
- Active/active on **adama + roslin + ai-server-4080**; NPM balances upstreams.
|
||||||
|
- Deployed by:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/Documents/repos/server-infra/scripts/deploy.sh \
|
||||||
|
--app chat_backend --env prod --ref <sha>
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI / CD (Gitea Actions)
|
||||||
|
|
||||||
|
| Workflow | Trigger | Action |
|
||||||
|
|----------|---------|--------|
|
||||||
|
| `unittests.yml` | push + PR → `master` | `uv sync` + `manage.py test` |
|
||||||
|
| `ci.yml` | PR → `master` | same unit tests |
|
||||||
|
| `deploy.yml` | after Unit Tests succeeds on `master` **push** | docker build + tests on **ephemeral compose Postgres** → `deploy.sh` |
|
||||||
|
|
||||||
|
Deploy never runs on PRs.
|
||||||
|
|
||||||
|
## Security note
|
||||||
|
|
||||||
|
Secrets previously hardcoded in `settings.py` (email password, captcha, Django
|
||||||
|
secret) must live only in the control-node env file. Rotate anything that was
|
||||||
|
ever committed; never commit `.env` or `~/Documents/secrets/`.
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import OperationalError
|
from django.db import OperationalError, ProgrammingError
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
class ChatBackendConfig(AppConfig):
|
class ChatBackendConfig(AppConfig):
|
||||||
@@ -10,14 +12,26 @@ class ChatBackendConfig(AppConfig):
|
|||||||
def ready(self):
|
def ready(self):
|
||||||
import chat_backend.signals
|
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
|
FORCE_RELOAD = False
|
||||||
|
|
||||||
if True: # not settings.TESTING: # Don't run during tests
|
|
||||||
try:
|
try:
|
||||||
from .services.rag_services import AsyncRAGService
|
from .services.rag_services import AsyncRAGService
|
||||||
from chat_backend.models import Document
|
from chat_backend.models import Document
|
||||||
|
|
||||||
# Check if Chroma needs initialization
|
|
||||||
if Document.objects.exists():
|
if Document.objects.exists():
|
||||||
rag_service = AsyncRAGService()
|
rag_service = AsyncRAGService()
|
||||||
|
|
||||||
@@ -27,6 +41,9 @@ class ChatBackendConfig(AppConfig):
|
|||||||
if FORCE_RELOAD:
|
if FORCE_RELOAD:
|
||||||
print("Force Reload ChromaDB with existing documents...")
|
print("Force Reload ChromaDB with existing documents...")
|
||||||
rag_service.clear_vector_store()
|
rag_service.clear_vector_store()
|
||||||
except OperationalError:
|
except (OperationalError, ProgrammingError):
|
||||||
# Database tables might not exist yet during migration
|
# Database tables might not exist yet during migration
|
||||||
pass
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
# Ollama/Chroma unreachable must not block process start.
|
||||||
|
print(f"Skipping RAG init at startup: {exc}")
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ llama client - Abstract this in the future
|
|||||||
|
|
||||||
import ollama
|
import ollama
|
||||||
from typing import List, Dict
|
from typing import List, Dict
|
||||||
|
from chat_backend.ollama_config import ollama_base_url
|
||||||
|
|
||||||
|
|
||||||
class LlamaClient(object):
|
class LlamaClient(object):
|
||||||
def __init__(self, model: str = "llama3"):
|
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
|
self.model = model
|
||||||
|
|
||||||
def check_if_model_exists(self) -> bool:
|
def check_if_model_exists(self) -> bool:
|
||||||
@@ -23,7 +24,7 @@ class LlamaClient(object):
|
|||||||
return " ".join(raw_response.split()[:4])
|
return " ".join(raw_response.split()[:4])
|
||||||
|
|
||||||
def generate_single_message(self, message: str):
|
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]):
|
def get_chat_response(self, messages: List[str]):
|
||||||
return self.client.chat(model=self.model, messages=messages, stream=False)
|
return self.client.chat(model=self.model, messages=messages, stream=False)
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ from channels.layers import get_channel_layer
|
|||||||
from asgiref.sync import sync_to_async, async_to_sync
|
from asgiref.sync import sync_to_async, async_to_sync
|
||||||
from langchain_core.messages import HumanMessage, AIMessage
|
from langchain_core.messages import HumanMessage, AIMessage
|
||||||
from langchain_community.vectorstores import Chroma
|
from langchain_community.vectorstores import Chroma
|
||||||
from langchain_community.embeddings import OllamaEmbeddings
|
from langchain_ollama import OllamaEmbeddings
|
||||||
from langchain_community.tools import DuckDuckGoSearchRun
|
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.runnables import RunnableLambda, RunnableBranch, RunnablePassthrough
|
||||||
from langchain_core.tracers.context import collect_runs
|
from langchain_core.tracers.context import collect_runs
|
||||||
|
|
||||||
@@ -177,9 +179,12 @@ def get_retriever(conversation_id):
|
|||||||
logger.info(f"Got conversation: {conversation}")
|
logger.info(f"Got conversation: {conversation}")
|
||||||
workspace = DocumentWorkspace.objects.get(company=conversation.user.company)
|
workspace = DocumentWorkspace.objects.get(company=conversation.user.company)
|
||||||
logger.info(f"Got workspace: {conversation}")
|
logger.info(f"Got workspace: {conversation}")
|
||||||
|
persist_directory = getattr(
|
||||||
|
django_settings, "CHROMA_PERSIST_DIRECTORY", "./chroma_db/"
|
||||||
|
)
|
||||||
vectorstore = Chroma(
|
vectorstore = Chroma(
|
||||||
persist_directory=f"./chroma_db/",
|
persist_directory=persist_directory,
|
||||||
embedding=OllamaEmbeddings(model="llama3.2"),
|
embedding=OllamaEmbeddings(**ollama_embeddings_kwargs()),
|
||||||
)
|
)
|
||||||
return vectorstore.as_retriever()
|
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/",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,11 +2,11 @@ from django.db import models
|
|||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from autoslug import AutoSlugField
|
from autoslug import AutoSlugField
|
||||||
from django.core.files.storage import FileSystemStorage
|
from chat_backend.storage import DatabaseStorage
|
||||||
|
|
||||||
# Create your models here.
|
# Create your models here.
|
||||||
|
|
||||||
FILE_STORAGE = FileSystemStorage(location="prompt_files")
|
DB_FILE_STORAGE = DatabaseStorage()
|
||||||
|
|
||||||
|
|
||||||
class TimeInfoBase(models.Model):
|
class TimeInfoBase(models.Model):
|
||||||
@@ -169,7 +169,11 @@ class Prompt(TimeInfoBase):
|
|||||||
"Conversation", on_delete=models.CASCADE, blank=True, null=True
|
"Conversation", on_delete=models.CASCADE, blank=True, null=True
|
||||||
)
|
)
|
||||||
file = models.FileField(
|
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(
|
file_type = models.CharField(
|
||||||
max_length=16,
|
max_length=16,
|
||||||
@@ -232,7 +236,23 @@ class DocumentWorkspace(TimeInfoBase):
|
|||||||
|
|
||||||
class Document(TimeInfoBase):
|
class Document(TimeInfoBase):
|
||||||
workspace = models.ForeignKey(DocumentWorkspace, on_delete=models.CASCADE)
|
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)
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||||
processed = models.BooleanField(default=False)
|
processed = models.BooleanField(default=False)
|
||||||
active = 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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from langchain_ollama import OllamaLLM
|
from langchain_ollama import OllamaLLM
|
||||||
from langchain_core.output_parsers import StrOutputParser
|
from langchain_core.output_parsers import StrOutputParser
|
||||||
from django.conf import settings
|
from chat_backend.ollama_config import ollama_llm_kwargs
|
||||||
|
|
||||||
|
|
||||||
class BaseService(ABC):
|
class BaseService(ABC):
|
||||||
"""Abstract base class for LLM conversation services."""
|
"""Abstract base class for LLM conversation services."""
|
||||||
|
|
||||||
def __init__(self, temperature=0.7):
|
def __init__(self, temperature=0.7):
|
||||||
self.llm = OllamaLLM(
|
self.llm = OllamaLLM(
|
||||||
model="llama3.2" if not settings.DEBUG else "gpt-oss:20b",
|
**ollama_llm_kwargs(
|
||||||
temperature=0.7,
|
temperature=temperature,
|
||||||
top_k=50,
|
top_k=50,
|
||||||
top_p=0.9,
|
top_p=0.9,
|
||||||
repeat_penalty=1.1,
|
repeat_penalty=1.1,
|
||||||
num_ctx=4096,
|
num_ctx=4096,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
self.output_parser = StrOutputParser()
|
self.output_parser = StrOutputParser()
|
||||||
@@ -11,6 +11,7 @@ from langchain_core.output_parsers import StrOutputParser
|
|||||||
import docx
|
import docx
|
||||||
import pypdf
|
import pypdf
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from chat_backend.ollama_config import ollama_llm_kwargs
|
||||||
|
|
||||||
|
|
||||||
class AsyncDataAnalysisService:
|
class AsyncDataAnalysisService:
|
||||||
@@ -19,10 +20,11 @@ class AsyncDataAnalysisService:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
# A model with a large context window and strong analytical skills is best
|
# A model with a large context window and strong analytical skills is best
|
||||||
self.llm = OllamaLLM(
|
self.llm = OllamaLLM(
|
||||||
model="llama3.2" if not settings.DEBUG else "gpt-oss:20b",
|
**ollama_llm_kwargs(
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
num_ctx=8192,
|
num_ctx=8192,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
self.output_parser = StrOutputParser()
|
self.output_parser = StrOutputParser()
|
||||||
self._setup_chain()
|
self._setup_chain()
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from langchain_core.prompts import ChatPromptTemplate
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from chat_backend.models import Conversation, Prompt
|
from chat_backend.models import Conversation, Prompt
|
||||||
|
from chat_backend.ollama_config import ollama_llm_kwargs
|
||||||
|
|
||||||
|
|
||||||
class LLMService(ABC):
|
class LLMService(ABC):
|
||||||
@@ -15,13 +16,14 @@ class LLMService(ABC):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.llm = OllamaLLM(
|
self.llm = OllamaLLM(
|
||||||
model="llama3.2" if not settings.DEBUG else "gpt-oss:20b",
|
**ollama_llm_kwargs(
|
||||||
temperature=0.7,
|
temperature=0.7,
|
||||||
top_k=50,
|
top_k=50,
|
||||||
top_p=0.9,
|
top_p=0.9,
|
||||||
repeat_penalty=1.1,
|
repeat_penalty=1.1,
|
||||||
num_ctx=4096,
|
num_ctx=4096,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
self.output_parser = StrOutputParser()
|
self.output_parser = StrOutputParser()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Dict, Any
|
|||||||
from langchain_core.prompts import ChatPromptTemplate
|
from langchain_core.prompts import ChatPromptTemplate
|
||||||
from langchain_ollama import OllamaLLM
|
from langchain_ollama import OllamaLLM
|
||||||
from chat_backend.services.base_service import BaseService
|
from chat_backend.services.base_service import BaseService
|
||||||
|
from chat_backend.ollama_config import ollama_llm_kwargs
|
||||||
|
|
||||||
|
|
||||||
class ModerationLabel(Enum):
|
class ModerationLabel(Enum):
|
||||||
@@ -19,11 +20,12 @@ class ModerationClassifier(BaseService):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(temperature=0.1)
|
super().__init__(temperature=0.1)
|
||||||
self.llm = OllamaLLM(
|
self.llm = OllamaLLM(
|
||||||
model="llama3.2",
|
**ollama_llm_kwargs(
|
||||||
temperature=0.1, # Very low for strict moderation
|
temperature=0.1, # Very low for strict moderation
|
||||||
top_k=10,
|
top_k=10,
|
||||||
num_ctx=2048,
|
num_ctx=2048,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
self.moderation_prompt = ChatPromptTemplate.from_messages(
|
self.moderation_prompt = ChatPromptTemplate.from_messages(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import List, Dict, Any, AsyncGenerator, Generator, Optional
|
from typing import List, Dict, Any, AsyncGenerator, Generator, Optional
|
||||||
from channels.db import database_sync_to_async
|
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 django.conf import settings
|
||||||
|
|
||||||
# from langchain_community.llms import Ollama
|
|
||||||
from langchain_ollama import OllamaLLM
|
from langchain_ollama import OllamaLLM
|
||||||
from langchain_community.vectorstores import Chroma
|
from langchain_community.vectorstores import Chroma
|
||||||
from langchain_core.documents import Document as LangDocument
|
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 chat_backend.models import Conversation, Prompt, DocumentWorkspace, Document
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from chat_backend.services.base_service import BaseService
|
from chat_backend.services.base_service import BaseService
|
||||||
|
from chat_backend.ollama_config import ollama_embeddings_kwargs
|
||||||
|
|
||||||
|
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
@@ -45,7 +47,7 @@ class RAGService(BaseService):
|
|||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def __init__(self):
|
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__()
|
super().__init__()
|
||||||
self.text_splitter = RecursiveCharacterTextSplitter(
|
self.text_splitter = RecursiveCharacterTextSplitter(
|
||||||
chunk_size=1000, chunk_overlap=200
|
chunk_size=1000, chunk_overlap=200
|
||||||
@@ -63,7 +65,10 @@ class RAGService(BaseService):
|
|||||||
|
|
||||||
def _initialize_vector_store(self) -> Chroma:
|
def _initialize_vector_store(self) -> Chroma:
|
||||||
"""Initialize and return the Chroma vector store."""
|
"""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(
|
vector_store = Chroma(
|
||||||
embedding_function=self.embedding_model, persist_directory=persist_directory
|
embedding_function=self.embedding_model, persist_directory=persist_directory
|
||||||
)
|
)
|
||||||
@@ -74,19 +79,54 @@ class RAGService(BaseService):
|
|||||||
self.vector_store.delete_collection()
|
self.vector_store.delete_collection()
|
||||||
self.vector_store = self._initialize_vector_store()
|
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]:
|
def _prepare_documents(self, documents: List[Document]) -> List[Document]:
|
||||||
"""Process documents for ingestion into vector store."""
|
"""Process documents for ingestion into vector store."""
|
||||||
docs = []
|
docs = []
|
||||||
|
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
print(f"Processing: {doc.file.name}")
|
print(f"Processing: {doc.file.name}")
|
||||||
loader_class = self._get_file_loader(doc.file.name)
|
tmp_path = self._materialize_file_field(doc.file)
|
||||||
loader = loader_class(doc.file)
|
try:
|
||||||
|
chunks = self._load_and_split_documents(
|
||||||
chunks = self._load_and_split_documents(doc.file.path)
|
tmp_path,
|
||||||
|
metadata={
|
||||||
|
"source": doc.file.name,
|
||||||
|
"workspace_id": doc.workspace_id,
|
||||||
|
"document_id": doc.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
if chunks:
|
if chunks:
|
||||||
self.vector_store.add_documents(chunks)
|
self.vector_store.add_documents(chunks)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.unlink(tmp_path)
|
||||||
self.vector_store.persist()
|
self.vector_store.persist()
|
||||||
|
return docs
|
||||||
|
|
||||||
def ingest_documents(self, workspace: DocumentWorkspace | None = None) -> None:
|
def ingest_documents(self, workspace: DocumentWorkspace | None = None) -> None:
|
||||||
"""Ingest documents from a workspace into the vector store."""
|
"""Ingest documents from a workspace into the vector store."""
|
||||||
@@ -99,18 +139,6 @@ class RAGService(BaseService):
|
|||||||
print(f"Processing the documents : {documents}")
|
print(f"Processing the documents : {documents}")
|
||||||
self._prepare_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):
|
def _get_file_loader(self, file_path: str):
|
||||||
"""Get appropriate loader for file type"""
|
"""Get appropriate loader for file type"""
|
||||||
ext = Path(file_path).suffix.lower()
|
ext = Path(file_path).suffix.lower()
|
||||||
@@ -120,18 +148,6 @@ class RAGService(BaseService):
|
|||||||
"""Sanitize filename for safe storage"""
|
"""Sanitize filename for safe storage"""
|
||||||
return re.sub(r"[^\w\-_. ]", "_", filename)
|
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(
|
def _load_and_split_documents(
|
||||||
self, file_path: str, metadata: dict = None
|
self, file_path: str, metadata: dict = None
|
||||||
) -> List[Document]:
|
) -> List[Document]:
|
||||||
@@ -148,46 +164,47 @@ class RAGService(BaseService):
|
|||||||
|
|
||||||
def add_files_to_store(
|
def add_files_to_store(
|
||||||
self,
|
self,
|
||||||
file_tupls: List[UploadedFile], # (file_path, name,workspace_id)
|
file_tupls: List, # (file_path_or_field, name, workspace_id)
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
source: str = "upload",
|
source: str = "upload",
|
||||||
save_dir: str = "data/uploads",
|
save_dir: str = "data/uploads",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Process and add uploaded files to vector store
|
Process and add files to vector store.
|
||||||
|
|
||||||
Args:
|
file_tupls entries: (path_str | Django FileField, original_name, workspace_id)
|
||||||
files: List of Django UploadedFile objects
|
Paths may be temp files; FileFields are materialized from DB storage.
|
||||||
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
|
|
||||||
"""
|
"""
|
||||||
results = {"total_added": 0, "failed_files": [], "processed_files": []}
|
results = {"total_added": 0, "failed_files": [], "processed_files": []}
|
||||||
|
|
||||||
for file_tuple in file_tupls:
|
for file_tuple in file_tupls:
|
||||||
|
tmp_created = None
|
||||||
try:
|
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 = {
|
metadata = {
|
||||||
"source": file_tuple[1],
|
"source": original_name,
|
||||||
"workspace_id": file_tuple[2],
|
"workspace_id": ws_id,
|
||||||
"original_filename": file_tuple[1],
|
"original_filename": original_name,
|
||||||
"file_path": file_tuple[0],
|
"file_path": original_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Load and split documents
|
|
||||||
docs = self._load_and_split_documents(file_path, metadata)
|
docs = self._load_and_split_documents(file_path, metadata)
|
||||||
|
|
||||||
# Add to vector store
|
|
||||||
if docs:
|
if docs:
|
||||||
self.vector_store.add_documents(docs)
|
self.vector_store.add_documents(docs)
|
||||||
results["total_added"] += len(docs)
|
results["total_added"] += len(docs)
|
||||||
results["processed_files"].append(
|
results["processed_files"].append(
|
||||||
{"filename": file_tuple[1], "document_count": len(docs)}
|
{"filename": original_name, "document_count": len(docs)}
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -195,8 +212,10 @@ class RAGService(BaseService):
|
|||||||
{"filename": file_tuple[1], "error": str(e)}
|
{"filename": file_tuple[1], "error": str(e)}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
finally:
|
||||||
|
if tmp_created and os.path.exists(tmp_created):
|
||||||
|
os.unlink(tmp_created)
|
||||||
|
|
||||||
# Persist changes
|
|
||||||
self.vector_store.persist()
|
self.vector_store.persist()
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -245,7 +264,6 @@ class SyncRAGService(RAGService):
|
|||||||
query = input_dict["query"]
|
query = input_dict["query"]
|
||||||
conversation = input_dict["conversation"]
|
conversation = input_dict["conversation"]
|
||||||
|
|
||||||
# You could enhance this to consider historical context in retrieval
|
|
||||||
relevant_docs = self.search_documents(query, conversation.workspace)
|
relevant_docs = self.search_documents(query, conversation.workspace)
|
||||||
if not relevant_docs:
|
if not relevant_docs:
|
||||||
print("didn't find any relevant docs")
|
print("didn't find any relevant docs")
|
||||||
@@ -260,10 +278,11 @@ class SyncRAGService(RAGService):
|
|||||||
filter_dict = {}
|
filter_dict = {}
|
||||||
if workspace:
|
if workspace:
|
||||||
filter_dict["workspace_id"] = workspace.id
|
filter_dict["workspace_id"] = workspace.id
|
||||||
|
search_kwargs = {"k": k, "filter": filter_dict if filter_dict else None}
|
||||||
print(f"search_kwargs: {search_kwargs}")
|
print(f"search_kwargs: {search_kwargs}")
|
||||||
retriever = self.vector_store.as_retriever(
|
retriever = self.vector_store.as_retriever(
|
||||||
search_type="similarity",
|
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)
|
return retriever.get_relevant_documents(query)
|
||||||
|
|
||||||
@@ -299,7 +318,7 @@ class AsyncRAGService(RAGService):
|
|||||||
self.rag_chain = (
|
self.rag_chain = (
|
||||||
{
|
{
|
||||||
"context": self._retriever_with_history,
|
"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"],
|
"question": lambda x: x["query"],
|
||||||
}
|
}
|
||||||
| self.prompt
|
| self.prompt
|
||||||
@@ -309,16 +328,12 @@ class AsyncRAGService(RAGService):
|
|||||||
|
|
||||||
async def _format_history(self, conversation: Conversation) -> str:
|
async def _format_history(self, conversation: Conversation) -> str:
|
||||||
"""Format conversation history for the prompt."""
|
"""Format conversation history for the prompt."""
|
||||||
# prompts = (
|
return "\n".join(
|
||||||
# await Prompt.objects.filter(conversation=conversation)
|
[
|
||||||
# .order_by("created_at")
|
f'{"User" if prompt.type == "human" else "AI"}: {prompt.text()}'
|
||||||
# .alist()
|
for prompt in conversation
|
||||||
# )
|
]
|
||||||
# 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])
|
|
||||||
|
|
||||||
async def _retriever_with_history(self, input_dict: Dict[str, Any]) -> str:
|
async def _retriever_with_history(self, input_dict: Dict[str, Any]) -> str:
|
||||||
"""Retrieve documents considering conversation history."""
|
"""Retrieve documents considering conversation history."""
|
||||||
@@ -327,7 +342,6 @@ class AsyncRAGService(RAGService):
|
|||||||
conversation = input_dict["conversation"]
|
conversation = input_dict["conversation"]
|
||||||
workspace = input_dict["workspace"]
|
workspace = input_dict["workspace"]
|
||||||
|
|
||||||
# You could enhance this to consider historical context in retrieval
|
|
||||||
docs = await self.search_documents(query, workspace)
|
docs = await self.search_documents(query, workspace)
|
||||||
|
|
||||||
if not docs:
|
if not docs:
|
||||||
|
|||||||
@@ -1,251 +1,32 @@
|
|||||||
import os
|
import os
|
||||||
from unittest import TestCase, mock
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch, AsyncMock
|
from unittest import TestCase
|
||||||
from typing import List, Dict, Any
|
|
||||||
|
|
||||||
from django.test import TestCase as DjangoTestCase
|
from chat_backend.services.prompt_classifier.prompt_classifier import (
|
||||||
|
PromptClassifier,
|
||||||
from chat_backend.services.rag_services import (
|
PromptType,
|
||||||
RAGService,
|
|
||||||
SyncRAGService,
|
|
||||||
AsyncRAGService,
|
|
||||||
)
|
)
|
||||||
from chat_backend.models import Conversation, Prompt, DocumentWorkspace, Document
|
|
||||||
from chat_backend.services.prompt_classifier import PromptClassifier, PromptType
|
|
||||||
from parameterized import parameterized
|
from parameterized import parameterized
|
||||||
|
|
||||||
|
|
||||||
# class TestRAGService(TestCase):
|
@unittest.skipIf(
|
||||||
# def setUp(self):
|
os.environ.get("SKIP_RAG_INIT", "").lower() in {"1", "true", "yes"},
|
||||||
# self.rag_service = RAGService()
|
"Requires live Ollama; skipped when SKIP_RAG_INIT is set",
|
||||||
# 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)
|
|
||||||
|
|
||||||
class PromptClassifierTestCase(TestCase):
|
class PromptClassifierTestCase(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.service = PromptClassifier()
|
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],
|
["Tell me a joke", 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],
|
["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,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
def test_prompt_classification(self, prompt, expected_output):
|
def test_prompt_classification(self, prompt, expected_output):
|
||||||
result = self.service.classify(prompt)
|
result = self.service.classify(prompt)
|
||||||
self.assertEqual(result, expected_output)
|
self.assertEqual(result, expected_output)
|
||||||
@@ -3,6 +3,7 @@ from langchain_core.prompts import ChatPromptTemplate
|
|||||||
# from langchain_community.llms import Ollama
|
# from langchain_community.llms import Ollama
|
||||||
from langchain_ollama import OllamaLLM
|
from langchain_ollama import OllamaLLM
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from chat_backend.ollama_config import ollama_llm_kwargs
|
||||||
|
|
||||||
|
|
||||||
class TitleGenerator:
|
class TitleGenerator:
|
||||||
@@ -12,11 +13,12 @@ class TitleGenerator:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.llm = OllamaLLM(
|
self.llm = OllamaLLM(
|
||||||
model="llama3.2",
|
**ollama_llm_kwargs(
|
||||||
temperature=0.5, # Slightly creative but not too random
|
temperature=0.5, # Slightly creative but not too random
|
||||||
top_k=20,
|
top_k=20,
|
||||||
num_ctx=2048, # Shorter context needed for titles
|
num_ctx=2048, # Shorter context needed for titles
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
self.title_prompt = ChatPromptTemplate.from_messages(
|
self.title_prompt = ChatPromptTemplate.from_messages(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,20 +1,40 @@
|
|||||||
from django.db.models.signals import post_save, post_delete
|
from django.db.models.signals import post_save, post_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
from django.conf import settings
|
||||||
|
import os
|
||||||
|
|
||||||
from chat_backend.models import Document
|
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)
|
@receiver(post_save, sender=Document)
|
||||||
def update_vector_on_save(sender, instance, **kwargs):
|
def update_vector_on_save(sender, instance, **kwargs):
|
||||||
"""Update vector store when documents are saved"""
|
"""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 = AsyncRAGService()
|
||||||
rag_service.ingest_documents()
|
rag_service.ingest_documents()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Skipping vector update on Document save: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=Document)
|
@receiver(post_delete, sender=Document)
|
||||||
def delete_vector_on_remove(sender, instance, **kwargs):
|
def delete_vector_on_remove(sender, instance, **kwargs):
|
||||||
"""Handle document deletion by re-indexing the whole workspace"""
|
"""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 = AsyncRAGService()
|
||||||
rag_service.ingest_documents()
|
rag_service.ingest_documents()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Skipping vector update on Document delete: {exc}")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -58,7 +58,7 @@ from django.core.files.base import ContentFile
|
|||||||
import math
|
import math
|
||||||
import datetime
|
import datetime
|
||||||
import pytz
|
import pytz
|
||||||
from langchain_community.embeddings import OllamaEmbeddings
|
from langchain_ollama import OllamaEmbeddings
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
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.moderation_classifier import moderation_classifier, ModerationLabel
|
||||||
from .services.prompt_classifier.prompt_classifier import PromptClassifier, PromptType
|
from .services.prompt_classifier.prompt_classifier import PromptClassifier, PromptType
|
||||||
from .services.data_analysis_service import AsyncDataAnalysisService
|
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"
|
CHANNEL_NAME: str = "llm_messages"
|
||||||
MODEL_NAME: str = "llama3.2"
|
MODEL_NAME: str = ollama_model()
|
||||||
|
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
class CustomObtainTokenView(TokenObtainPairView):
|
class CustomObtainTokenView(TokenObtainPairView):
|
||||||
@@ -717,7 +718,7 @@ prompt = ChatPromptTemplate.from_messages(
|
|||||||
[("system", "You are a helpful assistant."), ("user", "{input}")]
|
[("system", "You are a helpful assistant."), ("user", "{input}")]
|
||||||
)
|
)
|
||||||
|
|
||||||
llm = OllamaLLM(model=MODEL_NAME)
|
llm = OllamaLLM(**ollama_llm_kwargs(model=MODEL_NAME))
|
||||||
|
|
||||||
# output_parser = StrOutputParser()
|
# output_parser = StrOutputParser()
|
||||||
# # Chain
|
# # Chain
|
||||||
@@ -790,14 +791,13 @@ class DocumentUploadView(APIView):
|
|||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
def process_document(self, document):
|
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.processed = True
|
||||||
document.active = True
|
document.active = True
|
||||||
document.save()
|
document.save()
|
||||||
service = AsyncRAGService()
|
service = AsyncRAGService()
|
||||||
service.add_files_to_store(
|
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,
|
workspace_id=document.workspace_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+149
-135
@@ -1,46 +1,136 @@
|
|||||||
"""
|
"""
|
||||||
Django settings for llm_be project.
|
Django settings for llm_be project.
|
||||||
|
|
||||||
Generated by 'django-admin startproject' using Django 3.2.18.
|
Environment-driven for local/dev/beta/prod (match company_site / scha).
|
||||||
|
|
||||||
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/
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
def env(key: str, default: str | None = None) -> str | None:
|
||||||
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
|
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!
|
def env_bool(key: str, default: bool = False) -> bool:
|
||||||
DEBUG = False
|
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
|
CORS_ALLOW_CREDENTIALS = False
|
||||||
ALLOWED_HOSTS = [
|
CORS_ORIGIN_ALLOW_ALL = env_bool("CORS_ORIGIN_ALLOW_ALL", True)
|
||||||
"*.aimloperations.com",
|
CORS_ALLOWED_ORIGINS = env_list(
|
||||||
"localhost",
|
"CORS_ALLOWED_ORIGINS",
|
||||||
"127.0.0.1",
|
"http://localhost:3000,http://127.0.0.1:3000,https://chat.aimloperations.com",
|
||||||
"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"]
|
|
||||||
|
|
||||||
|
# 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 = [
|
INSTALLED_APPS = [
|
||||||
"daphne",
|
"daphne",
|
||||||
@@ -49,6 +139,7 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
|
"whitenoise.runserver_nostatic",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"chat_backend",
|
"chat_backend",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
@@ -58,23 +149,18 @@ INSTALLED_APPS = [
|
|||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
|
||||||
"django.middleware.common.CommonMiddleware",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "llm_be.urls"
|
ROOT_URLCONF = "llm_be.urls"
|
||||||
|
|
||||||
# SETTINGS_PATH = os.path.dirname(os.path.dirname(__file__))
|
|
||||||
# TEMPLATE_DIRS = (
|
|
||||||
# os.path.join(SETTINGS_PATH, 'templates'),
|
|
||||||
# )
|
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
@@ -94,20 +180,7 @@ TEMPLATES = [
|
|||||||
WSGI_APPLICATION = "llm_be.wsgi.application"
|
WSGI_APPLICATION = "llm_be.wsgi.application"
|
||||||
ASGI_APPLICATION = "llm_be.asgi.application"
|
ASGI_APPLICATION = "llm_be.asgi.application"
|
||||||
|
|
||||||
|
DATABASES = database_config()
|
||||||
# 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
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
@@ -124,40 +197,35 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Internationalization
|
|
||||||
# https://docs.djangoproject.com/en/3.2/topics/i18n/
|
|
||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
TIME_ZONE = "UTC"
|
TIME_ZONE = "UTC"
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
USE_L10N = True
|
USE_L10N = True
|
||||||
|
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
|
||||||
# https://docs.djangoproject.com/en/3.2/howto/static-files/
|
|
||||||
|
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
|
MEDIA_URL = "/media/"
|
||||||
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
|
|
||||||
# Default primary key field type
|
STORAGES = {
|
||||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
|
"default": {
|
||||||
|
"BACKEND": "chat_backend.storage.DatabaseStorage",
|
||||||
|
},
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
# custom user model
|
|
||||||
AUTH_USER_MODEL = "chat_backend.CustomUser"
|
AUTH_USER_MODEL = "chat_backend.CustomUser"
|
||||||
|
|
||||||
# rest framework jwt stuff
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||||
), #
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
SIMPLE_JWT = {
|
SIMPLE_JWT = {
|
||||||
@@ -175,85 +243,31 @@ SIMPLE_JWT = {
|
|||||||
"TOKEN_TYPE_CLAIM": "token_type",
|
"TOKEN_TYPE_CLAIM": "token_type",
|
||||||
}
|
}
|
||||||
|
|
||||||
# CORS settings
|
|
||||||
CORS_ALLOWED_ORIGINS = [
|
|
||||||
"http://localhost:3000",
|
|
||||||
"http://127.0.0.1:3000",
|
|
||||||
]
|
|
||||||
|
|
||||||
# channel settings
|
|
||||||
CHANNEL_LAYERS = {
|
CHANNEL_LAYERS = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "channels.layers.InMemoryChannelLayer",
|
"BACKEND": "channels.layers.InMemoryChannelLayer",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# # Office 365 settings
|
EMAIL_HOST = env("EMAIL_HOST", "mail.smtp2go.com") or "mail.smtp2go.com"
|
||||||
# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
EMAIL_HOST_USER = env("EMAIL_HOST_USER", "") or ""
|
||||||
# EMAIL_HOST = os.getenv("APP_EMAIL_HOST", "smtp.office365.com")
|
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", "") or ""
|
||||||
# EMAIL_PORT = os.getenv("APP_EMAIL_PORT", 587)
|
EMAIL_PORT = int(env("EMAIL_PORT", "2525") or "2525")
|
||||||
# EMAIL_HOST_USER = "ryan@aimloperations.com"#os.getenv("APP_EMAIL_HOST_USER")
|
EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True)
|
||||||
# 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)
|
|
||||||
|
|
||||||
# SMTP2GO
|
CAPTCHA_SECRET_KEY = env("CAPTCHA_SECRET_KEY", "") or ""
|
||||||
EMAIL_HOST = "mail.smtp2go.com"
|
|
||||||
EMAIL_HOST_USER = "info.aimloperations.com"
|
|
||||||
EMAIL_HOST_PASSWORD = "ZDErIII2sipNNVMz"
|
|
||||||
EMAIL_PORT = 2525
|
|
||||||
EMAIL_USE_TLS = True
|
|
||||||
|
|
||||||
# Captcha
|
USE_TLS_PROXY = env_bool("USE_TLS_PROXY", DJANGO_ENV in {"prod", "beta"})
|
||||||
CAPTCHA_SECRET_KEY = "6LfENu4qAAAAABdrj6JTviq-LfdPP5imhE-Os7h9"
|
if USE_TLS_PROXY:
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
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,
|
|
||||||
# },
|
|
||||||
# },
|
|
||||||
# }
|
|
||||||
|
|
||||||
|
directory_path = env("DJANGO_LOG_DIR", str(BASE_DIR / "logs")) or str(BASE_DIR / "logs")
|
||||||
os.makedirs(directory_path, exist_ok=True)
|
os.makedirs(directory_path, exist_ok=True)
|
||||||
|
|
||||||
# Feature Flags
|
ALLOW_IMAGE_GENERATION = env_bool("ALLOW_IMAGE_GENERATION", False)
|
||||||
ALLOW_IMAGE_GENERATION = False
|
ALLOW_INTERNET_ACCESS = env_bool("ALLOW_INTERNET_ACCESS", True)
|
||||||
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.")
|
||||||
|
|||||||
@@ -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
|
||||||
Executable
+53
@@ -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}"
|
||||||
Executable
+52
@@ -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)."
|
||||||
Reference in New Issue
Block a user