westfarn 0d6bf2b024
CI / test (pull_request) Successful in 10s
Unit Tests / test (pull_request) Successful in 10s
Support personal Drive/RAG without a company (#55).
Personal Drive connections and document workspaces are user-owned and
no longer require company_id. Company Drive still requires a company
manager. Chat/document scope falls back to a personal workspace for
solo users instead of rejecting with company_missing.
2026-08-02 05:40:53 -05:00
2025-12-07 06:31:06 -06:00
2025-03-07 12:23:00 -06:00

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_URL for 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
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
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.

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. Chromas vector index may still use a volume (chroma_db); that is embeddings metadata, not the original upload.

Production / beta (docker-compose.prod.yml)

  • Single web service; 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)

Plan change and cancel stay on Stripe Customer Portal (POST /api/finance/portal/). Local state syncs via customer.subscription.updated / deleted webhooks. 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/ Sync now (chat_backend/services/drive_sync.py::sync_connection)

Provider scope differences:

  • Google: same drive.readonly scope for personal and company; company sync reads Shared Drives via corpora=drive + supportsAllDrives.
  • Microsoft: personal uses Files.Read; company uses Files.Read.All Sites.Read.All and syncs SharePoint sites (selected_resource_ids = site ids).

Workers / webhooks (#52):

  • python manage.py sync_drive_connections [--connection-id N] — cron/worker entry point.
  • POST /api/drive/webhooks/google/ / POST /api/drive/webhooks/microsoft/ — provider push-notification stubs (AllowAny); acknowledge 200 and call sync_connection when the notification's connection_id is resolvable, else just 200 (no-op). Microsoft's subscription-creation validationToken handshake is echoed back as text/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/.

S
Description
Backend django project for the chat site
Readme
1.2 MiB
Languages
Python 98.9%
HTML 0.7%
Shell 0.3%