## Summary - Closes [#33](#33) — per-user `use_conversation_context` boolean on `CustomUser` (default `false`, opt-in). - Migration `0034_customuser_use_conversation_context`. - `GET/POST /api/conversation_preferences` reads/writes the flag; POST sets absolute boolean without toggling `conversation_order` when only this field is sent. - Chat + document RAG paths skip prior-turn history when the flag is off (`AsyncLLMService`, `AsyncRAGService`, both WS consumers). ## Frontend contract - Field: `use_conversation_context` (boolean, default false) - Read: `GET /api/conversation_preferences` → `{ order, use_conversation_context }` - Write: `POST /api/conversation_preferences` with `{ "use_conversation_context": true|false }` - Tooltip copy: use previous conversations to better customize the experience - Counterpart: [chat_web_app#66](ai_ml_operations/chat_web_app#66) ## Test plan - [ ] `manage.py test chat_backend.tests.test_models chat_backend.tests.test_views_conversations.ConversationPreferencesTestCase chat_backend.tests.test_services_llm chat_backend.tests.test_services_rag` - [ ] New user / unset flag → `use_conversation_context` is false; history empty in LLM/RAG prompts - [ ] Enable via preferences API → prior turns appear in history - [ ] Disable again → history skipped; order preference unchanged when posting only the context flagReviewed-on: #73
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 |
gpt-oss:20b |
optional | Legacy fallback for THINKING |
OLLAMA_MODEL_THINKING / _FAST / _UTILITY |
see defaults | optional | Role-scoped chat models (#62) |
OLLAMA_EMBED_MODEL |
nomic-embed-text |
optional | Never falls back to a chat model |
OLLAMA_NUM_CTX_THINKING / _FAST |
16384 / 8192 |
optional | Context window per role |
ALLOW_INTERNET_ACCESS |
true |
optional | Gate for live web retrieval |
SEARCH_PROVIDER |
searxng |
optional | Primary search provider (#62) |
SEARCH_FAILOVER_PROVIDER |
ddgs |
optional | Automatic failover |
SEARXNG_BASE_URL |
http://127.0.0.1:8088 |
yes if using SearxNG | Self-hosted SearxNG JSON API |
EMAIL_HOST_* |
empty | yes (prod/beta) | SMTP2GO |
CAPTCHA_SECRET_KEY |
empty | recommended | |
ENABLE_ACCOUNT_REGISTRATION |
false |
optional | Self-serve sign-up; keep false until ready |
STRIPE_SECRET_KEY / STRIPE_PUBLISHABLE_KEY / STRIPE_WEBHOOK_SECRET |
empty | yes for billing | Stripe API + webhook (monetization app) |
REVENUECAT_WEBHOOK_SECRET |
empty | yes for store IAP | RevenueCat webhook Authorization bearer |
REVENUECAT_PRODUCT_PLAN_MAP |
empty JSON | optional | {"product_id":"plan_slug"} fallback map |
STRIPE_PRICE_ID |
empty | optional | Pre-created Price; else $10/mo from settings |
GOOGLE_OAUTH_CLIENT_ID / ..._SECRET |
empty | for SSO/Drive | Also used for Drive linking (#47), incremental scopes |
MICROSOFT_OAUTH_CLIENT_ID / ..._SECRET / ..._TENANT |
empty / common |
for SSO/Drive | Also used for Drive linking (#47), incremental scopes |
FRONTEND_BASE_URL |
http://localhost:3000 |
set in prod/beta | Checkout success/cancel, portal return, OAuth return |
STRIPE_PORTAL_RETURN_URL |
{FRONTEND}/account/ |
optional | Stripe Customer Portal return URL |
CORS_ALLOWED_ORIGINS |
local + chat FE (+ beta FE default) | set in prod/beta | Frontend origin(s) |
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 |
Assistant identity (Hesychia) lives in code:
llm_be/chat_backend/services/assistant_identity.py — prepended to user-facing
generation prompts (chat, RAG, data analysis). Not env-configurable.
Templates: .env.example (local), .env.prod.example (control-node secret).
Control-node secret paths (server-infra on ai-server-4080):
~/Documents/secrets/chat_backend/chat_backend_prod.env
~/Documents/secrets/chat_backend/chat_backend_beta.env
Validate with:
./scripts/validate-env.sh ~/Documents/secrets/chat_backend/chat_backend_prod.env
./scripts/validate-env.sh ~/Documents/secrets/chat_backend/chat_backend_beta.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.
Role-scoped models (#62)
| Role | Setting | Default | Used for |
|---|---|---|---|
| THINKING | OLLAMA_MODEL_THINKING |
gpt-oss:20b |
Default chat / grounded answers |
| FAST | OLLAMA_MODEL_FAST |
gemma4:latest |
FE modelName=FAST (smaller/faster — still grounded) |
| UTILITY | OLLAMA_MODEL_UTILITY |
llama3.2 |
Classify / moderate / title / grounding decision |
| EMBED | OLLAMA_EMBED_MODEL |
nomic-embed-text |
Chroma embeddings |
After changing OLLAMA_EMBED_MODEL, rebuild the vector store (dimension change):
SKIP_RAG_INIT=1 uv run python manage.py reindex_embeddings
SearxNG (web search)
Grounded chat uses a self-hosted SearxNG instance as the primary search
provider (SEARCH_PROVIDER=searxng), with DuckDuckGo (ddgs) as automatic
failover. Point SEARXNG_BASE_URL at the JSON API (no trailing path).
Recommended: run SearxNG on the GPU/infra host next to Ollama
(10.0.0.128), reachable from the chat_backend containers on the LAN.
Minimal compose snippet (add to server-infra or run on ai-server-4080):
services:
searxng:
image: searxng/searxng:latest
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./searxng:/etc/searxng:rw
environment:
- SEARXNG_BASE_URL=http://10.0.0.128:8088/
In searxng/settings.yml (created on first start), enable the JSON format:
search:
formats:
- html
- json
Then set in chat_backend_prod.env / chat_backend_beta.env:
ALLOW_INTERNET_ACCESS=true
SEARCH_PROVIDER=searxng
SEARCH_FAILOVER_PROVIDER=ddgs
SEARXNG_BASE_URL=http://10.0.0.128:8088
Firewall: allow 10.0.0.0/24 → :8080 on the SearxNG host (same pattern as
Ollama :11434). Verify from a backend container:
curl -sG 'http://10.0.0.128:8088/search' --data-urlencode 'q=test' -d 'format=json' | head
If SearxNG is down, chat still works for non-factual turns; factual turns that require retrieval return an explicit "couldn't reach live sources" message instead of hallucinating from parametric memory.
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 / beta (docker-compose.prod.yml)
- Single
webservice; no bundled DB —DATABASE_URL→ shared Postgres (10.0.0.230). - Host port from
WEB_PORT(prod 8003; beta 8013). - Entrypoint: wait DB → migrate → collectstatic →
gunicorn+UvicornWorker(ASGI for HTTP and WebSockets). - Active/active on adama + roslin + ai-server-4080; NPM balances upstreams.
- Manual / local deploy:
# beta (day-to-day)
~/Documents/repos/server-infra/scripts/deploy.sh \
--app chat_backend --env beta --ref <sha>
# prod (intentional)
~/Documents/repos/server-infra/scripts/deploy.sh \
--app chat_backend --env prod --ref <sha>
Beta hosts / CORS: beta.chatbackend.aimloperations.com API +
https://beta.chat.aimloperations.com SPA (see .env.prod.example beta block).
DB: chat_backend_beta. Pair with server-infra#7
and frontend chat_web_app#35.
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-beta.yml |
push to master |
unit tests → docker compose tests → deploy.sh --env beta |
deploy-prod.yml |
manual workflow_dispatch only |
unit tests → docker compose tests → deploy.sh --env prod |
Push/merge to master auto-deploys beta only. Prod requires the Gitea
Run workflow button on Deploy Prod. Deploy never runs on PRs.
Frontend API notes
Self-delete account (#34)
| Method / path | DELETE /api/user/ |
| Auth | JWT (authenticated user only; always deletes request.user) |
| Optional body | { "refresh_token": "<current refresh>" } |
| Success | 200 { "detail": "Account deleted.", "deleted": true } |
| Effects | Sets deleted=True, is_active=False; soft-deletes conversations; blacklists outstanding refresh tokens; logs UserAuthEvent account_deleted |
| Staff | Staff/superuser self-delete rejected (400, code=staff_forbidden) |
| Privacy v1 | Soft-delete only (no anonymization / hard purge) |
Post-delete UX: clear local tokens → redirect to sign-in. Subsequent
/token/obtain/ fails. Do not send another user's id/email — ignored.
Subscription change / cancel (portal + webhooks)
Billing lives in the monetization Django app (package rename of
finance; DB tables keep the finance_* prefix via app label = "finance").
URLs: /api/monetization/... and alias /api/finance/....
Web: plan change/cancel stay on Stripe Customer Portal
(POST /api/finance/portal/). Local state syncs via
customer.subscription.updated / deleted webhooks.
Native (Play / App Store): RevenueCat webhooks at
POST /api/finance/webhooks/revenuecat/ (Authorization bearer =
REVENUECAT_WEBHOOK_SECRET) upsert Invoice/Payment ledger rows and sync
UserSubscription (source=revenuecat).
GET /api/finance/subscription/ includes cancel_at_period_end and
current_period_end for Account UI messaging.
Subscription audit (UserAuthEvent on the user admin):
subscription_started— first active plan (Checkout, Backer redeem, admin assign)subscription_updated— plan/status/cancel-at-period-end changes (portal + webhooks)
Drive / RAG sync (#47-#53)
Personal Google Drive / OneDrive and company Google Shared Drive / SharePoint
sync into the existing RAG Document pipeline. Personal connections work for
users without a company (personal workspace); company connections require a
company manager. Every endpoint below is gated
by assert_feature_allowed(user, "rag") (SubscriptionPlan.allows_rag —
true for Founders/Pro/Business/Backer, false for Standard by default).
Connect (OAuth, reuses #24 SSO app registrations with incremental scopes):
| Personal | GET /api/auth/oauth/<google|microsoft>/start/?intent=link_drive (authenticated) |
| Company | GET /api/auth/oauth/<google|microsoft>/start/?intent=link_company_drive (company manager only) |
| Callback | Same /api/auth/oauth/<provider>/callback/ as SSO; the signed OAuth state carries the linking user_id since the browser has no session on the IdP redirect. Upserts a DriveConnection and redirects to {FRONTEND_BASE_URL}/account/?drive_connected=1&provider=<provider>&kind=<personal|company> (or ?error=<code>) |
Manage:
| Method / path | Notes |
|---|---|
GET /api/drive/connections/ |
Caller's personal connections + their company's company connections |
DELETE /api/drive/connections/<id>/ |
Disconnect (owner for personal, company manager for company) — deactivates + clears tokens, keeps history |
POST /api/drive/connections/<id>/resources/ |
{ "resource_ids": [...], "resource_labels": [...] } — folder/shared-drive/site ids to sync; empty = provider root |
POST /api/drive/connections/<id>/sync/ |
Enqueue sync now (chat_backend/drive_tasks.py) — returns 202 with connection.last_sync_status=pending; poll GET /api/drive/connections/ for ok / error + last_sync_error |
Provider scope differences:
- Google: same
drive.readonlyscope for personal and company; company sync reads Shared Drives viacorpora=drive+supportsAllDrives. - Microsoft: personal uses
Files.Read; company usesFiles.Read.All Sites.Read.Alland syncs SharePoint sites (selected_resource_ids= site ids).
Workers / webhooks (#52, #57):
python manage.py sync_drive_connections [--connection-id N]— enqueue sync tasks (default).python manage.py sync_drive_connections --sync-now— run sync inline in this process (cron/debug).- Django 6
TASKS(seesettings.py): defaultImmediateBackendruns tasks in-process; Sync now still returns 202 by dispatching on a background thread. SwapTASKSto a durable queue + worker for production scale. POST /api/drive/webhooks/google//POST /api/drive/webhooks/microsoft/— provider push-notification stubs (AllowAny); acknowledge200and enqueuesync_connectionwhen the notification'sconnection_idis resolvable, else just200(no-op). Microsoft's subscription-creationvalidationTokenhandshake is echoed back astext/plain.
Google-native Docs/Sheets/Slides are exported to .docx/.xlsx/.pdf before
ingest (Chroma/RAG loaders don't read the native formats). Documents whose
remote file was deleted upstream are removed on the next sync.
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/.