Closes #5 ## Summary - Replaces the three scattered test modules (`chat_backend/tests.py`, `services/tests.py`, `services/prompt_classifier/tests.py`) with a `chat_backend/tests/` package: **242 deterministic tests plus 6 opt-in live-Ollama checks**, up from 10 tests (3 of which were skipped and 4 of which were never even discovered). - The suite runs fully offline — no Ollama, Chroma, SMTP or network access. LangChain runnables are replaced by a small `FakeChain`, Chroma/embeddings are mocked, email uses Django's locmem backend, and blobs go through `DatabaseStorage`. - New `llm_be/test_runner.py` (wired via `TEST_RUNNER`) sets `SKIP_RAG_INIT=1` and an MD5 password hasher, so the suite cannot accidentally reach a model server and finishes in ~5s on SQLite (~17s on Postgres) instead of ~30s. ## Coverage | Area | File | |------|------| | `TimeInfoBase.save`, slugs, `get_duration`, `file_exists`, cascades | `test_models.py` | | `DatabaseStorage` save/open/exists/size/listdir/delete/times | `test_storage.py` | | JWT claim, prompt/user/feedback/document serializers | `test_serializers.py` | | auth + token, invite, feedback, company users, set-password, TOS | `test_views_users.py` | | conversation list/order/create/detail/soft-delete | `test_views_conversations.py` | | all four analytics endpoints, including empty-month behaviour | `test_views_analytics.py` | | workspace + document upload/list/detail, 404 and 400 paths | `test_views_documents.py` | | prompt classifier rules/parsing, moderation fail-safe, title cleanup | `test_services_classifiers.py` | | CSV/XLSX/DOCX/PDF analysis, plot generation, error payloads | `test_services_data_analysis.py` | | loader selection, filename sanitising, ingest, temp-file cleanup, search filters | `test_services_rag.py` | | history formatting and streaming | `test_services_llm.py` | | document re-index on create/delete, `SKIP_RAG_INIT` guard | `test_signals.py` | | consumer DB helpers, LangGraph nodes (moderation, classification, generation, search flags), websocket routes | `test_consumers.py` | Live checks (non-deterministic, need a model server): ```bash cd llm_be RUN_LIVE_OLLAMA_TESTS=1 uv run python manage.py test chat_backend.tests.test_live_ollama ``` ## Bugs the tests surfaced (fixed here) 1. **`ConversationDetailView.post` silently dropped every prompt.** `import datetime` shadowed `from datetime import datetime`, so `datetime.now()` raised `AttributeError` inside a bare `except` and the endpoint returned 200 without saving. Now uses `timezone.now()`. 2. **Prompt attachments never reached the LLM.** `get_conversation_file_async` (both consumers) did `sync_to_async(prompt.file.read)` — with `DatabaseStorage` the attribute access itself opens the blob, i.e. a DB query in async context, raising `SynchronousOnlyOperation` that was swallowed and returned `(None, None)`. The read now happens inside the thread. 3. **`DatabaseStorage._save` crashed on a str-backed `ContentFile`** (`TypeError: sequence item 0: expected a bytes-like object`); chunks are encoded when needed. 4. **`services/prompt_classifier/__init__.,py`** (note the comma) meant the directory was only an implicit namespace package, which is why its test module was never collected. Renamed, and its duplicate live-Ollama tests folded into `test_live_ollama.py`. Known-broken paths deliberately left untested and unchanged: `reset_password` / `ResetUserPassword` reference an unimported `requests` plus undefined locals, and `DocumentDetailView.get` references an undefined `workspaces` on its success path. Worth a follow-up ticket. ## Test plan - [x] `cd llm_be && uv run python manage.py test` → 248 tests, OK (6 skipped, all opt-in live) - [x] Same suite against Postgres 16 (`DATABASE_URL=postgres://…`) → OK, matching the containerized run in `deploy.yml` - [x] `uv run black` clean on all added files - [ ] Gitea Actions **Unit Tests** + **CI** green on this PRReviewed-on: #12
5.8 KiB
Chat Backend
Django + Channels API for AIML Operations chat (chatbackend.aimloperations.com).
Packaging via uv; production deploy via server-infra.
Companion frontend: chat_web_app
(node-static, not Docker).
Ticket: chat_backend#6
Layout
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)
Local development
Prerequisites
- Python 3.12+
- uv
- Docker + Docker Compose (optional, recommended)
- Ollama reachable at
OLLAMA_BASE_URLfor LLM features
uv (host)
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
Without DATABASE_URL / DB_HOST, settings fall back to SQLite (llm_be/db.sqlite3).
Tests:
cd llm_be
uv run python manage.py test
The suite is offline by default — the custom test runner
(llm_be/test_runner.py) sets SKIP_RAG_INIT=1 and a cheap password hasher, and
LLM chains are faked, so no Ollama, Chroma, SMTP or network access is needed.
Tests live in llm_be/chat_backend/tests/ (models, storage, serializers, views,
services, signals, consumers).
Non-deterministic checks against a real model server are opt-in:
cd llm_be
RUN_LIVE_OLLAMA_TESTS=1 uv run python manage.py test chat_backend.tests.test_live_ollama
Docker (dev, bundled Postgres)
docker compose up --build
App: http://localhost:8003 — Postgres via bundled db
(postgres://chat_backend:chat_backend@db:5432/chat_backend).
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):
~/Documents/secrets/chat_backend/chat_backend_prod.env
Validate with:
./scripts/validate-env.sh ~/Documents/secrets/chat_backend/chat_backend_prod.env
If DATABASE_URL password contains $, escape each as $$ for Compose.
Ollama
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
webservice; 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:
~/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/.