Files
chat_backend/README.md
T
westfarn c5efe60e0b
CI / test (pull_request) Successful in 10s
Unit Tests / test (pull_request) Successful in 10s
Add tier-gated RAG and Drive document sources (#42)
Ship allows_rag entitlement (founders/backer/pro/business), enforce it on
document APIs and RAG chat, harden ingest/delete/active lifecycle, and add
Google/Microsoft Drive connect + sync for personal and company knowledge bases.

Closes #43 #44 #45 #46 #47 #48 #49 #50 #51 #52 #53
Parent epic: #42
Related: #11
2026-08-01 15:33:55 -05:00

261 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](https://git.aimloperations.com/ai_ml_operations/chat_web_app)
(node-static, not Docker).
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)
```
## Local development
### 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
```
Without `DATABASE_URL` / `DB_HOST`, settings fall back to SQLite (`llm_be/db.sqlite3`).
Tests:
```bash
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:
```bash
cd llm_be
RUN_LIVE_OLLAMA_TESTS=1 uv run python manage.py test chat_backend.tests.test_live_ollama
```
### Docker (dev, bundled Postgres)
```bash
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):
```text
~/Documents/secrets/chat_backend/chat_backend_prod.env
~/Documents/secrets/chat_backend/chat_backend_beta.env
```
Validate with:
```bash
./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:
```bash
# 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](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/7)
and frontend [chat_web_app#35](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/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](https://git.aimloperations.com/ai_ml_operations/chat_backend/issues/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](https://git.aimloperations.com/ai_ml_operations/chat_backend/issues/47)-[#53](https://git.aimloperations.com/ai_ml_operations/chat_backend/issues/53))
Personal Google Drive / OneDrive and company Google Shared Drive / SharePoint
sync into the existing RAG `Document` pipeline. 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/`.