Spaces:
Runtime error
Runtime error
GitHub Actions commited on
Commit ·
55c0d78
1
Parent(s): 4413b85
Deploy from GitHub Actions (2026-05-31 09:04 UTC)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .DS_Store +0 -0
- .dockerignore +31 -0
- .geminiignore +7 -0
- .gitignore +13 -5
- DOCKER_SETUP.md +69 -2
- Dockerfile +22 -45
- PROJECT.md +141 -0
- alembic/versions/00f16d0affc6_add_auth_providers_table_and_phone_to_.py +46 -0
- alembic/versions/05aee5d64d1b_add_context_summary_and_summarization_.py +47 -0
- alembic/versions/0704837a1594_add_backup_email_to_tenants.py +32 -0
- alembic/versions/126f5b1610a6_add_billing_tables.py +62 -0
- alembic/versions/3d1418b32af0_retire_gemini_2_0_flash_add_gemini_2_5_.py +64 -0
- alembic/versions/485fbe495e6b_add_feedback_columns_to_messages.py +34 -0
- alembic/versions/73ae38d1200c_add_retrieved_doc_ids_to_messages.py +32 -0
- alembic/versions/752c239e659a_add_rest_base_to_sync_jobs_expand_.py +40 -0
- alembic/versions/7992949ccdf5_add_industries_table.py +56 -0
- alembic/versions/7aec975a4140_credits_to_usd_refactor.py +64 -0
- alembic/versions/84de4a13cffd_add_ai_verdict_scores_to_messages.py +36 -0
- alembic/versions/9ad31e299e42_add_is_indexed_to_knowledge_entries.py +38 -0
- alembic/versions/9b95a5ce2ac1_add_gifted_billing_mode_check_constraint.py +30 -0
- alembic/versions/9d051350268a_seed_llm_models_initial_rates.py +59 -0
- alembic/versions/a53f4c8d9b21_kb_schema_redesign_and_sync_jobs.py +95 -0
- alembic/versions/ac67923a3324_add_created_updated_skipped_to_sync_jobs.py +36 -0
- alembic/versions/b58e26e702df_add_is_blocked_to_tenants_and_note_.py +36 -0
- alembic/versions/bf4016f7dd2a_add_website_url_to_tenants.py +32 -0
- alembic/versions/c9e1f8a2b7d4_add_auth_fields_to_tenants.py +39 -0
- alembic/versions/d1e126fba5fb_add_title_category_tag_to_kb_entries.py +36 -0
- alembic/versions/d2d812e2c226_add_last_message_preview_to_.py +32 -0
- alembic/versions/d7470fcd70c6_add_backup_codes_to_tenants.py +32 -0
- alembic/versions/e5cc44e2040a_make_tenant_id_non_nullable_on_users_.py +44 -0
- alembic/versions/f6988c25696e_add_customization_to_tenants.py +29 -0
- app/dependencies/__init__.py +0 -0
- app/dependencies/admin_auth.py +23 -0
- app/dependencies/super_admin_auth.py +27 -0
- app/main.py +216 -28
- app/middleware/tenant_auth.py +13 -2
- app/models/conversation_manager.py +281 -89
- app/models/database.py +324 -42
- app/models/information_extractor.py +12 -10
- app/models/smart_models.py +174 -49
- app/routers/admin_apikey.py +28 -0
- app/routers/admin_attention.py +26 -0
- app/routers/admin_auth.py +446 -0
- app/routers/admin_me.py +23 -0
- app/routers/admin_settings.py +68 -0
- app/routers/billing.py +33 -0
- app/routers/conversations.py +190 -0
- app/routers/knowledge_base.py +585 -14
- app/routers/super_admin_auth.py +58 -0
- app/routers/super_admin_rates.py +116 -0
.DS_Store
DELETED
|
Binary file (8.2 kB)
|
|
|
.dockerignore
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Secrets — never bake into image (COPY . . in Dockerfile would include these)
|
| 2 |
+
.env
|
| 3 |
+
.env.*
|
| 4 |
+
|
| 5 |
+
# Virtualenv — host Python (macOS) is incompatible with container Python (Linux)
|
| 6 |
+
.venv/
|
| 7 |
+
|
| 8 |
+
# Python bytecode — regenerated automatically, OS-specific
|
| 9 |
+
__pycache__/
|
| 10 |
+
*.pyc
|
| 11 |
+
*.pyo
|
| 12 |
+
*.pyd
|
| 13 |
+
.pytest_cache/
|
| 14 |
+
|
| 15 |
+
# Git history — app never needs it at runtime, and it can be hundreds of MB
|
| 16 |
+
.git/
|
| 17 |
+
|
| 18 |
+
# Frontend build tools — node_modules contains esbuild/vite (build-time only)
|
| 19 |
+
# The compiled output (chatbot-widget.js) is already built; these are never needed at runtime
|
| 20 |
+
frontend-widget/node_modules/
|
| 21 |
+
|
| 22 |
+
# Tests — bind-mounted in dev via docker-compose.dev.yml; not needed in prod image
|
| 23 |
+
tests/
|
| 24 |
+
|
| 25 |
+
# Project management and documentation — runtime irrelevant
|
| 26 |
+
archive/
|
| 27 |
+
docs/
|
| 28 |
+
current_state/
|
| 29 |
+
scripts/
|
| 30 |
+
.claude/
|
| 31 |
+
*.md
|
.geminiignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
.git/
|
| 3 |
+
node_modules/
|
| 4 |
+
__pycache__/
|
| 5 |
+
dist/
|
| 6 |
+
build/
|
| 7 |
+
*.log
|
.gitignore
CHANGED
|
@@ -133,6 +133,7 @@ celerybeat.pid
|
|
| 133 |
.env.staging
|
| 134 |
.env.production
|
| 135 |
.venv
|
|
|
|
| 136 |
env/
|
| 137 |
venv/
|
| 138 |
ENV/
|
|
@@ -189,18 +190,22 @@ cython_debug/
|
|
| 189 |
# Generated frontend configuration — tracked so CI/CD (GitHub Actions → HF Spaces) has it
|
| 190 |
# frontend/config.js
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
# Other Files or Directories
|
| 193 |
-
CLAUDE*.md
|
| 194 |
-
GEMINI*.md
|
| 195 |
-
AGENTS*.md
|
| 196 |
-
PROJECT.md
|
| 197 |
.claude/
|
| 198 |
.vscode/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
# Everything inside `archive` except `daily_log.md`
|
| 201 |
archive/*
|
| 202 |
!archive/daily_log.md
|
| 203 |
-
!archive/
|
| 204 |
|
| 205 |
# Microservice credentials (sensitive)
|
| 206 |
services/credentials/*.json
|
|
@@ -231,3 +236,6 @@ nginx/logs/
|
|
| 231 |
|
| 232 |
# Node.js (frontend-widget Vite/Preact source project)
|
| 233 |
frontend-widget/node_modules/
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
.env.staging
|
| 134 |
.env.production
|
| 135 |
.venv
|
| 136 |
+
.venv-linux
|
| 137 |
env/
|
| 138 |
venv/
|
| 139 |
ENV/
|
|
|
|
| 190 |
# Generated frontend configuration — tracked so CI/CD (GitHub Actions → HF Spaces) has it
|
| 191 |
# frontend/config.js
|
| 192 |
|
| 193 |
+
# ChromaDB persistent storage (runtime data)
|
| 194 |
+
chromadb/
|
| 195 |
+
chroma_data/
|
| 196 |
+
|
| 197 |
# Other Files or Directories
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
.claude/
|
| 199 |
.vscode/
|
| 200 |
+
*CLAUDE.md
|
| 201 |
+
*AGENTS.md
|
| 202 |
+
*GEMINI.md
|
| 203 |
+
docker-compose.dev.local.yml
|
| 204 |
|
| 205 |
# Everything inside `archive` except `daily_log.md`
|
| 206 |
archive/*
|
| 207 |
!archive/daily_log.md
|
| 208 |
+
!archive/knowledge_assessments/
|
| 209 |
|
| 210 |
# Microservice credentials (sensitive)
|
| 211 |
services/credentials/*.json
|
|
|
|
| 236 |
|
| 237 |
# Node.js (frontend-widget Vite/Preact source project)
|
| 238 |
frontend-widget/node_modules/
|
| 239 |
+
|
| 240 |
+
# Mac's metadata
|
| 241 |
+
**/.DS_Store
|
DOCKER_SETUP.md
CHANGED
|
@@ -126,13 +126,76 @@ docker-compose -f docker-compose.dev.yml up -d
|
|
| 126 |
docker-compose -f docker-compose.dev.yml logs -f smart-chatbot
|
| 127 |
```
|
| 128 |
|
| 129 |
-
#### 6.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
```bash
|
| 132 |
# Stop containers (keep data)
|
| 133 |
docker-compose -f docker-compose.dev.yml down
|
| 134 |
|
| 135 |
-
# Stop and remove volumes (fresh database)
|
| 136 |
docker-compose -f docker-compose.dev.yml down -v
|
| 137 |
```
|
| 138 |
|
|
@@ -181,6 +244,8 @@ docker-compose -f docker-compose.prod.yml up -d
|
|
| 181 |
- **Public API:** http://your-domain.com (via nginx)
|
| 182 |
- **Backend:** NOT accessible from outside (internal network only)
|
| 183 |
- **PostgreSQL:** NOT accessible from outside (internal network only)
|
|
|
|
|
|
|
| 184 |
|
| 185 |
#### 5. Verify Services are Running
|
| 186 |
|
|
@@ -197,6 +262,8 @@ docker-compose -f docker-compose.prod.yml ps | grep healthy
|
|
| 197 |
chatbot_backend_prod healthy
|
| 198 |
chatbot_nginx_prod healthy
|
| 199 |
chatbot_postgres_prod healthy
|
|
|
|
|
|
|
| 200 |
```
|
| 201 |
|
| 202 |
#### 6. View Logs
|
|
|
|
| 126 |
docker-compose -f docker-compose.dev.yml logs -f smart-chatbot
|
| 127 |
```
|
| 128 |
|
| 129 |
+
#### 6. Named Volumes (Dev)
|
| 130 |
+
|
| 131 |
+
| Volume | Purpose |
|
| 132 |
+
|--------|---------|
|
| 133 |
+
| `chatbot_postgres_dev` | PostgreSQL data — persists DB across restarts |
|
| 134 |
+
| `qdrant_dev_data` | Qdrant vector index data — production-target vector backend, mounted at `/qdrant/storage` |
|
| 135 |
+
| `redis_dev_data` | Redis append-only data for Taskiq WordPress sync jobs |
|
| 136 |
+
|
| 137 |
+
The production stack uses the same Qdrant storage path with `qdrant_prod_data` and stores Taskiq broker data in `redis_prod_data`. Startup runs the active vector-store readiness probe, so read-only volume or vector-service availability problems fail before tenant KB writes.
|
| 138 |
+
|
| 139 |
+
Vector backend environment variables:
|
| 140 |
+
|
| 141 |
+
| Variable | Purpose |
|
| 142 |
+
|----------|---------|
|
| 143 |
+
| `HF_HUB_DISABLE_XET` | Legacy HuggingFace Hub workaround; harmless if left set, no longer required for the Gemini embedding path |
|
| 144 |
+
| `GEMINI_EMBEDDING_API_KEY` | Gemini API key used by `EmbeddingService` for `gemini-embedding-2` requests |
|
| 145 |
+
| `VECTOR_STORE_BACKEND` | `qdrant` for V1 runtime; `chroma` remains available only as legacy fallback code |
|
| 146 |
+
| `QDRANT_URL` | Qdrant HTTP URL from app/worker containers, usually `http://qdrant:6333` |
|
| 147 |
+
| `QDRANT_COLLECTION_NAME` | Shared Qdrant collection for tenant KB vectors |
|
| 148 |
+
| `QDRANT_VECTOR_SIZE` | Embedding dimension; `3072` for `gemini-embedding-2` |
|
| 149 |
+
| `QDRANT_TIMEOUT` | Qdrant client timeout in seconds |
|
| 150 |
+
| `QDRANT_CONTAINER_NAME` | Docker container name for the Qdrant service |
|
| 151 |
+
|
| 152 |
+
Chat billing / completion envelope variables:
|
| 153 |
+
|
| 154 |
+
| Variable | Purpose |
|
| 155 |
+
|----------|---------|
|
| 156 |
+
| `CHAT_MODEL_PRIMARY` | Primary routed chat model alias target used by LiteLLM |
|
| 157 |
+
| `CHAT_MODEL_FALLBACKS` | Ordered fallback chat models used when the primary fails |
|
| 158 |
+
| `CHAT_MAX_OUTPUT_TOKENS` | Hard cap passed to chat completions and used by the low-balance preflight reserve check |
|
| 159 |
+
|
| 160 |
+
Rebuild one tenant's Qdrant index from PostgreSQL:
|
| 161 |
+
|
| 162 |
+
```bash
|
| 163 |
+
docker compose --env-file .env -f docker-compose.dev.yml exec smart-chatbot \
|
| 164 |
+
uv run python scripts/rebuild_tenant_index.py <tenant_uuid>
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
**Running tests — host vs Docker:**
|
| 168 |
+
|
| 169 |
+
`uv run pytest` on the host will produce 5 failures:
|
| 170 |
+
- `test_api_versioning.py::test_versioned_url_works`
|
| 171 |
+
- `test_input_token_limit.py::test_input_token_limit`
|
| 172 |
+
- `test_chat_billing_wiring.py::test_deducts_credits_on_credits_billing_mode`
|
| 173 |
+
- `test_chat_billing_wiring.py::test_returns_402_on_insufficient_credits`
|
| 174 |
+
- `test_chat_billing_wiring.py::test_skips_deduction_on_subscription_billing_mode`
|
| 175 |
+
|
| 176 |
+
Root cause: these tests call `retrieval_service.retrieve()` which connects to `QDRANT_URL=http://qdrant:6333`. That hostname only resolves inside the Docker network. No ports are exposed to the host, so the connection fails locally.
|
| 177 |
+
|
| 178 |
+
The full test suite must be run inside the container:
|
| 179 |
+
|
| 180 |
+
```bash
|
| 181 |
+
docker compose -f docker-compose.dev.yml exec chatbot_backend_dev uv run pytest tests -rAq --tb=no
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
Run the PostgreSQL-backed concurrent billing release-gate verification:
|
| 185 |
+
|
| 186 |
+
```bash
|
| 187 |
+
docker exec -w /project chatbot_backend_dev uv run python scripts/verify_postgres_concurrent_billing.py
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
Expected result: one concurrent `/chat` request returns `200`, all other burst requests return `402`, final tenant balance is `0.000000`, and exactly one `credit_events` row is persisted.
|
| 191 |
+
|
| 192 |
+
#### 7. Stop Development Environment
|
| 193 |
|
| 194 |
```bash
|
| 195 |
# Stop containers (keep data)
|
| 196 |
docker-compose -f docker-compose.dev.yml down
|
| 197 |
|
| 198 |
+
# Stop and remove volumes (fresh database + vector store)
|
| 199 |
docker-compose -f docker-compose.dev.yml down -v
|
| 200 |
```
|
| 201 |
|
|
|
|
| 244 |
- **Public API:** http://your-domain.com (via nginx)
|
| 245 |
- **Backend:** NOT accessible from outside (internal network only)
|
| 246 |
- **PostgreSQL:** NOT accessible from outside (internal network only)
|
| 247 |
+
- **Redis:** NOT accessible from outside (internal network only; used by Taskiq)
|
| 248 |
+
- **WordPress sync worker:** background Taskiq worker, not HTTP-accessible
|
| 249 |
|
| 250 |
#### 5. Verify Services are Running
|
| 251 |
|
|
|
|
| 262 |
chatbot_backend_prod healthy
|
| 263 |
chatbot_nginx_prod healthy
|
| 264 |
chatbot_postgres_prod healthy
|
| 265 |
+
chatbot_redis_prod healthy
|
| 266 |
+
wp_sync_worker_prod running
|
| 267 |
```
|
| 268 |
|
| 269 |
#### 6. View Logs
|
Dockerfile
CHANGED
|
@@ -24,7 +24,10 @@
|
|
| 24 |
# - Faster builds (shared layers cached once)
|
| 25 |
# ============================================================================
|
| 26 |
|
| 27 |
-
FROM python:
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
# Set working directory to project root
|
| 30 |
# NOTE: Changed from /app to /project to support absolute imports (app.*)
|
|
@@ -48,45 +51,13 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
|
| 48 |
# - uv.lock = Exact pinned versions of all dependencies (like package-lock.json)
|
| 49 |
COPY pyproject.toml uv.lock ./
|
| 50 |
|
| 51 |
-
#
|
| 52 |
-
#
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
#
|
| 57 |
-
|
| 58 |
-
#
|
| 59 |
-
# Why --system is required in Docker:
|
| 60 |
-
#
|
| 61 |
-
# WITHOUT --system (default behavior):
|
| 62 |
-
# - uv creates a virtual environment (.venv/) and installs packages there
|
| 63 |
-
# - Executables end up in: .venv/bin/uvicorn (hidden location)
|
| 64 |
-
# - Docker can't find them in PATH → Error: "No such file or directory"
|
| 65 |
-
# - You'd need to use "uv run uvicorn ..." which adds complexity
|
| 66 |
-
#
|
| 67 |
-
# WITH --system:
|
| 68 |
-
# - uv installs directly to system Python (no .venv/ folder created)
|
| 69 |
-
# - Executables go to: /usr/local/bin/uvicorn (standard PATH location)
|
| 70 |
-
# - Docker finds them immediately → uvicorn command just works
|
| 71 |
-
# - Cleaner CMD: just "uvicorn ..." instead of "uv run uvicorn ..."
|
| 72 |
-
#
|
| 73 |
-
# Why virtual environments (.venv/) are "extra" in Docker:
|
| 74 |
-
# On your laptop: .venv/ needed to separate multiple projects
|
| 75 |
-
# - Project A needs pandas 1.0
|
| 76 |
-
# - Project B needs pandas 2.0
|
| 77 |
-
# - .venv/ prevents conflicts
|
| 78 |
-
#
|
| 79 |
-
# In Docker: Container itself provides isolation
|
| 80 |
-
# - Container 1 has its own Python + packages (completely isolated)
|
| 81 |
-
# - Container 2 has its own Python + packages (can't see Container 1)
|
| 82 |
-
# - .venv/ inside container = locking a safe inside another safe (redundant)
|
| 83 |
-
#
|
| 84 |
-
# Analogy:
|
| 85 |
-
# Without --system = Hiding keys in a drawer (Docker can't find them)
|
| 86 |
-
# With --system = Putting keys on the key hook (Docker finds them easily)
|
| 87 |
-
#
|
| 88 |
-
# This replaces traditional: pip install -r requirements.txt
|
| 89 |
-
RUN uv pip install --system -r pyproject.toml
|
| 90 |
|
| 91 |
# Copy application code
|
| 92 |
# NOTE: Changed to copy entire project instead of just app/ directory
|
|
@@ -97,8 +68,14 @@ RUN uv pip install --system -r pyproject.toml
|
|
| 97 |
# Destination: . (current WORKDIR, which is /project inside container)
|
| 98 |
#
|
| 99 |
# Result: Files land at /project/app/main.py, /project/app/models/, etc.
|
|
|
|
| 100 |
COPY . .
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
# ============================================================================
|
| 103 |
# Why we DON'T set CMD or ENV here?
|
| 104 |
# - Base stage is INCOMPLETE - not meant to run directly
|
|
@@ -130,7 +107,7 @@ ENV DEBUG=true
|
|
| 130 |
|
| 131 |
# Development-specific command
|
| 132 |
# Flags explained:
|
| 133 |
-
# uvicorn = ASGI server for FastAPI (
|
| 134 |
# app.main:app = Import path (app/main.py file, app object) - using absolute import
|
| 135 |
# --reload = Watch for code changes, auto-restart server (hot reload)
|
| 136 |
# --host 0.0.0.0 = Listen on ALL network interfaces (required for Docker)
|
|
@@ -143,7 +120,7 @@ ENV DEBUG=true
|
|
| 143 |
# - --reload is EXPENSIVE (file watching, auto-restart) - only for development
|
| 144 |
# - Production uses gunicorn (multi-worker, more robust)
|
| 145 |
#
|
| 146 |
-
# Note: No "uv run" needed
|
| 147 |
CMD ["uvicorn", "app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"]
|
| 148 |
|
| 149 |
# Port documentation (doesn't actually open ports)
|
|
@@ -190,7 +167,7 @@ ENV DEBUG=false
|
|
| 190 |
# - Better stability, graceful shutdowns, worker health monitoring
|
| 191 |
#
|
| 192 |
# Flags explained:
|
| 193 |
-
# gunicorn = WSGI server (wraps uvicorn workers,
|
| 194 |
# app.main:app = Import path (app/main.py file, app object) - using absolute import
|
| 195 |
# -k uvicorn.workers.UvicornWorker = Use Uvicorn worker class (for ASGI support)
|
| 196 |
# -w 4 = 4 worker processes (adjust based on CPU cores)
|
|
@@ -202,7 +179,7 @@ ENV DEBUG=false
|
|
| 202 |
# Worker count rule of thumb: (2 × CPU_cores) + 1
|
| 203 |
# Example: 2-core server → 5 workers, 4-core server → 9 workers
|
| 204 |
#
|
| 205 |
-
# Note: No "uv run" needed
|
| 206 |
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "-w", "4", "-b", "0.0.0.0:8000"]
|
| 207 |
|
| 208 |
# Port documentation (doesn't actually open ports)
|
|
@@ -253,7 +230,7 @@ EXPOSE 7860
|
|
| 253 |
# docker images | grep smart-chatbot
|
| 254 |
#
|
| 255 |
# RUN DEVELOPMENT CONTAINER (standalone, no compose):
|
| 256 |
-
# docker run -p 8000:8000 -v $(pwd)/app:/app smart-chatbot:dev
|
| 257 |
#
|
| 258 |
# RUN PRODUCTION CONTAINER (standalone, no compose):
|
| 259 |
# docker run -p 8000:8000 smart-chatbot:prod
|
|
|
|
| 24 |
# - Faster builds (shared layers cached once)
|
| 25 |
# ============================================================================
|
| 26 |
|
| 27 |
+
FROM cgr.dev/chainguard/python:latest-dev AS base
|
| 28 |
+
|
| 29 |
+
# Chainguard sets ENTRYPOINT ["python"] — clear it so exec-form CMD works directly.
|
| 30 |
+
ENTRYPOINT []
|
| 31 |
|
| 32 |
# Set working directory to project root
|
| 33 |
# NOTE: Changed from /app to /project to support absolute imports (app.*)
|
|
|
|
| 51 |
# - uv.lock = Exact pinned versions of all dependencies (like package-lock.json)
|
| 52 |
COPY pyproject.toml uv.lock ./
|
| 53 |
|
| 54 |
+
# Chainguard runs as nonroot (uid 65532) — cannot write to system Python paths.
|
| 55 |
+
# Install into /project/.venv (owned by nonroot) and put it on PATH.
|
| 56 |
+
RUN uv venv /project/.venv && \
|
| 57 |
+
uv pip install --python /project/.venv/bin/python -r pyproject.toml
|
| 58 |
+
ENV PATH="/project/.venv/bin:$PATH"
|
| 59 |
+
# Avoid HuggingFace Hub Xet transfer stalls during startup embedding warmup.
|
| 60 |
+
ENV HF_HUB_DISABLE_XET=1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
# Copy application code
|
| 63 |
# NOTE: Changed to copy entire project instead of just app/ directory
|
|
|
|
| 68 |
# Destination: . (current WORKDIR, which is /project inside container)
|
| 69 |
#
|
| 70 |
# Result: Files land at /project/app/main.py, /project/app/models/, etc.
|
| 71 |
+
# Note: chroma_data/ is excluded via .dockerignore — created below with correct ownership.
|
| 72 |
COPY . .
|
| 73 |
|
| 74 |
+
# Pre-create ChromaDB data directory as nonroot so the named volume is initialised
|
| 75 |
+
# with correct ownership. Without this, Docker creates the volume mount point as root,
|
| 76 |
+
# and the nonroot process cannot write the SQLite file — causing 500 errors on all KB writes.
|
| 77 |
+
RUN mkdir -p /project/chroma_data
|
| 78 |
+
|
| 79 |
# ============================================================================
|
| 80 |
# Why we DON'T set CMD or ENV here?
|
| 81 |
# - Base stage is INCOMPLETE - not meant to run directly
|
|
|
|
| 107 |
|
| 108 |
# Development-specific command
|
| 109 |
# Flags explained:
|
| 110 |
+
# uvicorn = ASGI server for FastAPI (available via /project/.venv on PATH)
|
| 111 |
# app.main:app = Import path (app/main.py file, app object) - using absolute import
|
| 112 |
# --reload = Watch for code changes, auto-restart server (hot reload)
|
| 113 |
# --host 0.0.0.0 = Listen on ALL network interfaces (required for Docker)
|
|
|
|
| 120 |
# - --reload is EXPENSIVE (file watching, auto-restart) - only for development
|
| 121 |
# - Production uses gunicorn (multi-worker, more robust)
|
| 122 |
#
|
| 123 |
+
# Note: No "uv run" needed — venv bin is on PATH via ENV PATH above
|
| 124 |
CMD ["uvicorn", "app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"]
|
| 125 |
|
| 126 |
# Port documentation (doesn't actually open ports)
|
|
|
|
| 167 |
# - Better stability, graceful shutdowns, worker health monitoring
|
| 168 |
#
|
| 169 |
# Flags explained:
|
| 170 |
+
# gunicorn = WSGI server (wraps uvicorn workers, available via /project/.venv on PATH)
|
| 171 |
# app.main:app = Import path (app/main.py file, app object) - using absolute import
|
| 172 |
# -k uvicorn.workers.UvicornWorker = Use Uvicorn worker class (for ASGI support)
|
| 173 |
# -w 4 = 4 worker processes (adjust based on CPU cores)
|
|
|
|
| 179 |
# Worker count rule of thumb: (2 × CPU_cores) + 1
|
| 180 |
# Example: 2-core server → 5 workers, 4-core server → 9 workers
|
| 181 |
#
|
| 182 |
+
# Note: No "uv run" needed — venv bin is on PATH via ENV PATH above
|
| 183 |
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "-w", "4", "-b", "0.0.0.0:8000"]
|
| 184 |
|
| 185 |
# Port documentation (doesn't actually open ports)
|
|
|
|
| 230 |
# docker images | grep smart-chatbot
|
| 231 |
#
|
| 232 |
# RUN DEVELOPMENT CONTAINER (standalone, no compose):
|
| 233 |
+
# docker run -p 8000:8000 -v $(pwd)/app:/project/app smart-chatbot:dev
|
| 234 |
#
|
| 235 |
# RUN PRODUCTION CONTAINER (standalone, no compose):
|
| 236 |
# docker run -p 8000:8000 smart-chatbot:prod
|
PROJECT.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Smart Chatbot — Project Navigation
|
| 2 |
+
|
| 3 |
+
**What it is:** A chatbot-as-a-service (CaaS) microservice for SMEs. Tenants embed the widget on their site; the chatbot answers domain questions grounded in the tenant's knowledge base (RAG). Sold as prepaid token credits or monthly subscription.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Current Focus
|
| 8 |
+
|
| 9 |
+
**Next:** Phase 3 release gate — Simulated Tenant QA Pipeline (all prior gates including Qdrant migration are complete).
|
| 10 |
+
Full task spec: `current_state/project_status.md`
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## Tech Stack
|
| 15 |
+
|
| 16 |
+
| Layer | Technology |
|
| 17 |
+
|---|---|
|
| 18 |
+
| Backend | FastAPI, Python, SQLAlchemy, Alembic |
|
| 19 |
+
| Database | PostgreSQL (dev/prod), SQLite (HuggingFace Spaces) |
|
| 20 |
+
| Vector DB | Qdrant — shared collection with required `tenant_id` payload filter; ChromaDB legacy fallback code only |
|
| 21 |
+
| Embeddings | Google Gemini Embedding 2 (`gemini-embedding-2`, 3072-dim) |
|
| 22 |
+
| LLM | LiteLLM Router — provider-agnostic, model aliases in env vars, auto-fallback chains |
|
| 23 |
+
| Frontend | Preact + Shadow DOM widget, Vite build |
|
| 24 |
+
| Auth | API key (widget) + Google OAuth + TOTP 2FA + JWT (admin) |
|
| 25 |
+
| Infra | Docker multi-env, Nginx, Redis/Celery workers, Qdrant, HuggingFace Spaces |
|
| 26 |
+
| CI/CD | GitHub Actions |
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## Project Structure
|
| 31 |
+
|
| 32 |
+
```
|
| 33 |
+
app/
|
| 34 |
+
routers/ # Route handlers (knowledge_base, system_prompt)
|
| 35 |
+
schemas/ # Pydantic request/response models
|
| 36 |
+
middleware/ # TenantAuthMiddleware (API key → tenant_id)
|
| 37 |
+
models/ # SQLAlchemy models
|
| 38 |
+
services/ # Business logic (EmbeddingService, VectorStore, RetrievalService, LLMService, CreditService, WordPress sync, kb_reindex)
|
| 39 |
+
workers/ # Celery background workers (wordpress_sync, kb_reindex)
|
| 40 |
+
utils/ # api_key, token_counter, seeding
|
| 41 |
+
dependencies/ # FastAPI dependencies (admin_auth)
|
| 42 |
+
alembic/ # DB migrations
|
| 43 |
+
tests/ # 469 tests, organized by domain
|
| 44 |
+
auth/ # Admin + super-admin auth, JWT, TOTP, OAuth
|
| 45 |
+
knowledge_base/ # KB CRUD, chunking, pagination, reindex, sync
|
| 46 |
+
chat/ # Conversations, RAG context, input limits, history
|
| 47 |
+
billing/ # Credits, billing modes, concurrency
|
| 48 |
+
vector_store/ # Qdrant, ChromaDB, embedding service
|
| 49 |
+
workers/ # Summarization worker, WP sync jobs
|
| 50 |
+
llm/ # LLM service, retrieval, AI verdict, RAG
|
| 51 |
+
infra/ # Middleware, rate limiter, seeding, widget, system prompt
|
| 52 |
+
frontend/ # Preact chat widget (Vite build → chatbot-widget.js)
|
| 53 |
+
current_state/ # Architecture and status documents (see below)
|
| 54 |
+
archive/ # Historical notes and discussion logs (never retroactively edited)
|
| 55 |
+
docs/ # Specs and error logs
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## Key Architecture Decisions
|
| 61 |
+
|
| 62 |
+
**Multi-tenancy:** API key → `tenant_id` extracted in middleware. All DB queries and vector store operations are scoped to `tenant_id`. Qdrant uses one shared collection with required `tenant_id` payload filters inside the adapter; PostgreSQL remains the source of truth and Qdrant is a rebuildable search index.
|
| 63 |
+
|
| 64 |
+
**RAG pipeline:** `/chat` → `RetrievalService.retrieve(query, tenant_id)` → top-k KB docs injected into system message → LLM response grounded in KB.
|
| 65 |
+
|
| 66 |
+
**User auth model:** Two modes — `anonymous` (most SMEs: no end-user login, visitors are anonymous) and `authenticated` (SaaS/e-commerce: host page passes authenticated user identity). Toggle via admin panel config.
|
| 67 |
+
|
| 68 |
+
**LLM provider abstraction:** `LLMService` wraps LiteLLM Router. Model aliases (`chat-model`, `summarization-model`, `judge-llm`) declared in env vars — swapping providers requires only an env var change, no code change. Auto-fallback chains configured per alias. `complete_with_usage()` returns `LLMResult` with token counts for billing.
|
| 69 |
+
|
| 70 |
+
**API versioning:** All public endpoints prefixed `/api/v1/` — in place before first client embeds widget (one-way door).
|
| 71 |
+
|
| 72 |
+
**Widget embedding:** Preact + Shadow DOM — 3KB bundle, fully isolated from host-site CSS/JS, works on any tech stack (WordPress, Vue, React, plain HTML). Admin console generates per-tenant snippet: `<script src="widget.js" data-api-key="tenant_abc123"></script>`. Widget reads `data-api-key` at runtime.
|
| 73 |
+
|
| 74 |
+
**Per-tenant customization:** All theme/branding/chatbot identity settings stored as `customization JSONB` on `tenants` table — zero schema migrations for new settings. Only fields that need filtering (e.g. `credits`, `is_active`) get proper columns.
|
| 75 |
+
|
| 76 |
+
**Multi-tenant secrets:** Shared secrets (`GROQ_API_KEY`, `DATABASE_URL`) live in backend env. Per-tenant config (system prompt, KB, theme) lives in DB. BYOK tenants store their own LLM API key encrypted in `customization JSONB`.
|
| 77 |
+
|
| 78 |
+
**Credit deduction atomicity:** Single SQL `UPDATE tenants SET balance_usd = balance_usd - :amount WHERE id = :tenant_id AND balance_usd >= :amount` — check rows affected (1 = success, 0 = insufficient balance). No race condition possible without application-level locking.
|
| 79 |
+
|
| 80 |
+
---
|
| 81 |
+
|
| 82 |
+
## Key Files
|
| 83 |
+
|
| 84 |
+
| File | Purpose |
|
| 85 |
+
|---|---|
|
| 86 |
+
| `app/main.py` | Entry point — router registration, middleware, startup |
|
| 87 |
+
| `app/models/conversation_manager.py` | LLM call lives here — chat logic, context assembly |
|
| 88 |
+
| `app/models/nlp_engine.py` | Analytics and routing only — keep as-is |
|
| 89 |
+
| `app/utils/config.py` | Singleton config — safe to defer refactor |
|
| 90 |
+
| `.env` / `.env.staging` / `.env.production` | Environment-specific values |
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
## Business Model
|
| 95 |
+
|
| 96 |
+
| Tier | Best for | How it works |
|
| 97 |
+
|---|---|---|
|
| 98 |
+
| Prepaid credits | Informational sites, low-volume SMEs | Buy credits upfront, deducted per message |
|
| 99 |
+
| Monthly subscription | E-commerce, SaaS, high-volume SMEs | Fixed fee, predictable revenue |
|
| 100 |
+
|
| 101 |
+
Gifted accounts (V1 only): Monireach tops up manually via super-admin; no Stripe needed. Full payment integration is post-V1.
|
| 102 |
+
|
| 103 |
+
---
|
| 104 |
+
|
| 105 |
+
## Deployment Roadmap
|
| 106 |
+
|
| 107 |
+
| Stage | Tenants | Stack | Monthly cost |
|
| 108 |
+
|---|---|---|---|
|
| 109 |
+
| Now | 0–10 | HuggingFace Spaces (demo/portfolio) | Free |
|
| 110 |
+
| Early | 10–100 | Railway or Fly.io (managed, zero DevOps) | $10–30 |
|
| 111 |
+
| Growth | 100–1000 | Hetzner VPS + Docker Compose | €10–20 |
|
| 112 |
+
| Scale | 1000+ | Hetzner + K3s (lightweight Kubernetes) | €30–80 |
|
| 113 |
+
|
| 114 |
+
Target: Hetzner CAX31 ARM (4 vCPU, 8GB RAM, €14.10/month). PostgreSQL: Supabase managed at small scale → self-hosted at large scale. Admin panels: Cloudflare Pages (free). HF Space kept as public demo proxy only.
|
| 115 |
+
|
| 116 |
+
**K3s local practice (after V1 stable):** Deploy the full stack to a local K3s cluster in parallel with Docker Compose — not a replacement for the dev workflow, but a learning environment for writing real K8s manifests (`Deployment`, `Service`, `Ingress`, `ConfigMap`, `Secret`) against a real multi-service app. Makes the eventual Hetzner → K3s production migration familiar ground.
|
| 117 |
+
|
| 118 |
+
---
|
| 119 |
+
|
| 120 |
+
## Where to Find Things
|
| 121 |
+
|
| 122 |
+
| What you need | File |
|
| 123 |
+
|---|---|
|
| 124 |
+
| Task status, V1 checklist, current progress | `current_state/project_status.md` |
|
| 125 |
+
| Full details on completed tasks (sub-tasks, bugs, decisions) | `current_state/milestone.md` |
|
| 126 |
+
| Auth model, tenant isolation, API key design | `docs/security_architecture.md` |
|
| 127 |
+
| Frontend widget architecture, embedding, theming | `docs/frontend_architecture.md` |
|
| 128 |
+
| Khmer language strategy, LLM provider selection | `docs/khmer_llm_strategy.md` |
|
| 129 |
+
| Admin console scope and integration status | `~/projects/smart_chatbot_admin_console/PROJECT.md` |
|
| 130 |
+
| Super-admin console scope and task status | `~/projects/smart_chatbot_super_admin/current_state/project_status.md` |
|
| 131 |
+
| Docker setup, env vars, service topology | `DOCKER_SETUP.md` |
|
| 132 |
+
| Historical discussions (system expansion, billing design) | `archive/` |
|
| 133 |
+
|
| 134 |
+
---
|
| 135 |
+
|
| 136 |
+
## Live Deployments
|
| 137 |
+
|
| 138 |
+
| Environment | URL |
|
| 139 |
+
|---|---|
|
| 140 |
+
| HuggingFace Spaces (public API) | `https://huggingface.co/spaces/monireach88/smart-chatbot-api` |
|
| 141 |
+
| Portfolio (widget embedded) | `https://monireach.com` |
|
alembic/versions/00f16d0affc6_add_auth_providers_table_and_phone_to_.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add auth_providers table and phone to tenants
|
| 2 |
+
|
| 3 |
+
Revision ID: 00f16d0affc6
|
| 4 |
+
Revises: 752c239e659a
|
| 5 |
+
Create Date: 2026-05-28 11:35:39.306737
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '00f16d0affc6'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = '752c239e659a'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.create_table('tenant_auth_providers',
|
| 25 |
+
sa.Column('id', sa.UUID(), nullable=False),
|
| 26 |
+
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
| 27 |
+
sa.Column('provider', sa.String(length=20), nullable=False),
|
| 28 |
+
sa.Column('provider_user_id', sa.String(length=255), nullable=False),
|
| 29 |
+
sa.Column('linked_at', sa.DateTime(), nullable=False),
|
| 30 |
+
sa.CheckConstraint("provider IN ('google', 'telegram')", name=op.f('ck_tenant_auth_providers_chk_tenant_auth_providers_provider')),
|
| 31 |
+
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_tenant_auth_providers_tenant_id_tenants'), ondelete='CASCADE'),
|
| 32 |
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_tenant_auth_providers')),
|
| 33 |
+
sa.UniqueConstraint('tenant_id', 'provider', name='uq_tenant_auth_providers_tenant_id_provider')
|
| 34 |
+
)
|
| 35 |
+
op.create_index(op.f('ix_tenant_auth_providers_tenant_id'), 'tenant_auth_providers', ['tenant_id'], unique=False)
|
| 36 |
+
op.add_column('tenants', sa.Column('phone', sa.String(length=20), nullable=True))
|
| 37 |
+
# ### end Alembic commands ###
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def downgrade() -> None:
|
| 41 |
+
"""Downgrade schema."""
|
| 42 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 43 |
+
op.drop_column('tenants', 'phone')
|
| 44 |
+
op.drop_index(op.f('ix_tenant_auth_providers_tenant_id'), table_name='tenant_auth_providers')
|
| 45 |
+
op.drop_table('tenant_auth_providers')
|
| 46 |
+
# ### end Alembic commands ###
|
alembic/versions/05aee5d64d1b_add_context_summary_and_summarization_.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add_context_summary_and_summarization_job
|
| 2 |
+
|
| 3 |
+
Revision ID: 05aee5d64d1b
|
| 4 |
+
Revises: d2d812e2c226
|
| 5 |
+
Create Date: 2026-03-31 15:38:57.730352
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '05aee5d64d1b'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = 'd2d812e2c226'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.create_table('summarization_jobs',
|
| 25 |
+
sa.Column('id', sa.UUID(), nullable=False),
|
| 26 |
+
sa.Column('conversation_id', sa.UUID(), nullable=False),
|
| 27 |
+
sa.Column('status', sa.String(length=20), nullable=False),
|
| 28 |
+
sa.Column('attempt_count', sa.Integer(), nullable=False),
|
| 29 |
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
| 30 |
+
sa.Column('last_attempted_at', sa.DateTime(), nullable=True),
|
| 31 |
+
sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], name=op.f('fk_summarization_jobs_conversation_id_conversations')),
|
| 32 |
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_summarization_jobs'))
|
| 33 |
+
)
|
| 34 |
+
op.create_index(op.f('ix_summarization_jobs_conversation_id'), 'summarization_jobs', ['conversation_id'], unique=False)
|
| 35 |
+
op.create_index('uq_summarization_jobs_pending', 'summarization_jobs', ['conversation_id'], unique=True, postgresql_where=sa.text("status = 'pending'"))
|
| 36 |
+
op.add_column('conversations', sa.Column('context_summary', sa.Text(), nullable=True))
|
| 37 |
+
# ### end Alembic commands ###
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def downgrade() -> None:
|
| 41 |
+
"""Downgrade schema."""
|
| 42 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 43 |
+
op.drop_column('conversations', 'context_summary')
|
| 44 |
+
op.drop_index('uq_summarization_jobs_pending', table_name='summarization_jobs', postgresql_where=sa.text("status = 'pending'"))
|
| 45 |
+
op.drop_index(op.f('ix_summarization_jobs_conversation_id'), table_name='summarization_jobs')
|
| 46 |
+
op.drop_table('summarization_jobs')
|
| 47 |
+
# ### end Alembic commands ###
|
alembic/versions/0704837a1594_add_backup_email_to_tenants.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add backup email to tenants
|
| 2 |
+
|
| 3 |
+
Revision ID: 0704837a1594
|
| 4 |
+
Revises: d1e126fba5fb
|
| 5 |
+
Create Date: 2026-04-24 17:27:09.794919
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '0704837a1594'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = 'd1e126fba5fb'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.add_column('tenants', sa.Column('backup_email', sa.String(length=255), nullable=True))
|
| 25 |
+
# ### end Alembic commands ###
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def downgrade() -> None:
|
| 29 |
+
"""Downgrade schema."""
|
| 30 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 31 |
+
op.drop_column('tenants', 'backup_email')
|
| 32 |
+
# ### end Alembic commands ###
|
alembic/versions/126f5b1610a6_add_billing_tables.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add billing tables
|
| 2 |
+
|
| 3 |
+
Revision ID: 126f5b1610a6
|
| 4 |
+
Revises: d7470fcd70c6
|
| 5 |
+
Create Date: 2026-04-18 10:02:12.372002
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '126f5b1610a6'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = 'd7470fcd70c6'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.create_table('llm_models',
|
| 25 |
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
| 26 |
+
sa.Column('provider', sa.String(length=100), nullable=False),
|
| 27 |
+
sa.Column('model_name', sa.String(length=255), nullable=False),
|
| 28 |
+
sa.Column('credits_per_1k_input', sa.Numeric(precision=20, scale=6), nullable=False),
|
| 29 |
+
sa.Column('credits_per_1k_output', sa.Numeric(precision=20, scale=6), nullable=False),
|
| 30 |
+
sa.Column('effective_from', sa.Date(), nullable=False),
|
| 31 |
+
sa.Column('effective_to', sa.Date(), nullable=True),
|
| 32 |
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_llm_models'))
|
| 33 |
+
)
|
| 34 |
+
op.create_table('credit_events',
|
| 35 |
+
sa.Column('id', sa.UUID(), nullable=False),
|
| 36 |
+
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
| 37 |
+
sa.Column('event_type', sa.String(length=20), nullable=False),
|
| 38 |
+
sa.Column('amount', sa.Numeric(precision=20, scale=6), nullable=False),
|
| 39 |
+
sa.Column('balance_after', sa.Numeric(precision=20, scale=6), nullable=False),
|
| 40 |
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
| 41 |
+
sa.Column('request_id', sa.String(length=255), nullable=True),
|
| 42 |
+
sa.Column('llm_model_id', sa.Integer(), nullable=True),
|
| 43 |
+
sa.ForeignKeyConstraint(['llm_model_id'], ['llm_models.id'], name=op.f('fk_credit_events_llm_model_id_llm_models')),
|
| 44 |
+
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_credit_events_tenant_id_tenants')),
|
| 45 |
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_credit_events')),
|
| 46 |
+
sa.UniqueConstraint('tenant_id', 'request_id', name='uq_credit_events_tenant_id_request_id')
|
| 47 |
+
)
|
| 48 |
+
op.create_index(op.f('ix_credit_events_tenant_id'), 'credit_events', ['tenant_id'], unique=False)
|
| 49 |
+
op.add_column('tenants', sa.Column('credits', sa.Numeric(precision=20, scale=6), server_default='0', nullable=False))
|
| 50 |
+
op.add_column('tenants', sa.Column('billing_mode', sa.String(length=20), server_default='credits', nullable=False))
|
| 51 |
+
# ### end Alembic commands ###
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def downgrade() -> None:
|
| 55 |
+
"""Downgrade schema."""
|
| 56 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 57 |
+
op.drop_column('tenants', 'billing_mode')
|
| 58 |
+
op.drop_column('tenants', 'credits')
|
| 59 |
+
op.drop_index(op.f('ix_credit_events_tenant_id'), table_name='credit_events')
|
| 60 |
+
op.drop_table('credit_events')
|
| 61 |
+
op.drop_table('llm_models')
|
| 62 |
+
# ### end Alembic commands ###
|
alembic/versions/3d1418b32af0_retire_gemini_2_0_flash_add_gemini_2_5_.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""retire_gemini_2_0_flash_add_gemini_2_5_flash_rates
|
| 2 |
+
|
| 3 |
+
Revision ID: 3d1418b32af0
|
| 4 |
+
Revises: 9d051350268a
|
| 5 |
+
Create Date: 2026-04-19 02:38:42.878677
|
| 6 |
+
|
| 7 |
+
Pricing basis (2026-04-19): $0.30/1M input, $2.50/1M output (paid tier) × 3x markup.
|
| 8 |
+
1 credit ≈ $0.001 USD. gemini-2.0-flash retired per Google shutdown (2026-06-01).
|
| 9 |
+
"""
|
| 10 |
+
from typing import Sequence, Union
|
| 11 |
+
|
| 12 |
+
from alembic import op
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# revision identifiers, used by Alembic.
|
| 16 |
+
revision: str = '3d1418b32af0'
|
| 17 |
+
down_revision: Union[str, Sequence[str], None] = '9d051350268a'
|
| 18 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 19 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def upgrade() -> None:
|
| 23 |
+
# Close out gemini-2.0-flash row
|
| 24 |
+
op.execute("""
|
| 25 |
+
UPDATE llm_models
|
| 26 |
+
SET effective_to = '2026-04-19'
|
| 27 |
+
WHERE provider = 'gemini'
|
| 28 |
+
AND model_name = 'gemini-2.0-flash'
|
| 29 |
+
AND effective_to IS NULL
|
| 30 |
+
""")
|
| 31 |
+
|
| 32 |
+
# Insert gemini-2.5-flash row (idempotent)
|
| 33 |
+
op.execute("""
|
| 34 |
+
INSERT INTO llm_models
|
| 35 |
+
(provider, model_name, credits_per_1k_input, credits_per_1k_output,
|
| 36 |
+
effective_from, effective_to)
|
| 37 |
+
SELECT 'gemini', 'gemini-2.5-flash', 0.900000, 7.500000, '2026-04-19', NULL
|
| 38 |
+
WHERE NOT EXISTS (
|
| 39 |
+
SELECT 1 FROM llm_models
|
| 40 |
+
WHERE provider = 'gemini'
|
| 41 |
+
AND model_name = 'gemini-2.5-flash'
|
| 42 |
+
AND effective_to IS NULL
|
| 43 |
+
)
|
| 44 |
+
""")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def downgrade() -> None:
|
| 48 |
+
# Remove gemini-2.5-flash row
|
| 49 |
+
op.execute("""
|
| 50 |
+
DELETE FROM llm_models
|
| 51 |
+
WHERE provider = 'gemini'
|
| 52 |
+
AND model_name = 'gemini-2.5-flash'
|
| 53 |
+
AND effective_from = '2026-04-19'
|
| 54 |
+
AND effective_to IS NULL
|
| 55 |
+
""")
|
| 56 |
+
|
| 57 |
+
# Reopen gemini-2.0-flash row
|
| 58 |
+
op.execute("""
|
| 59 |
+
UPDATE llm_models
|
| 60 |
+
SET effective_to = NULL
|
| 61 |
+
WHERE provider = 'gemini'
|
| 62 |
+
AND model_name = 'gemini-2.0-flash'
|
| 63 |
+
AND effective_to = '2026-04-19'
|
| 64 |
+
""")
|
alembic/versions/485fbe495e6b_add_feedback_columns_to_messages.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add feedback columns to messages
|
| 2 |
+
|
| 3 |
+
Revision ID: 485fbe495e6b
|
| 4 |
+
Revises: 05aee5d64d1b
|
| 5 |
+
Create Date: 2026-04-05 11:57:40.860101
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '485fbe495e6b'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = '05aee5d64d1b'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.add_column('messages', sa.Column('user_feedback', sa.SmallInteger(), nullable=True))
|
| 25 |
+
op.add_column('messages', sa.Column('tenant_feedback', sa.SmallInteger(), nullable=True))
|
| 26 |
+
# ### end Alembic commands ###
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def downgrade() -> None:
|
| 30 |
+
"""Downgrade schema."""
|
| 31 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 32 |
+
op.drop_column('messages', 'tenant_feedback')
|
| 33 |
+
op.drop_column('messages', 'user_feedback')
|
| 34 |
+
# ### end Alembic commands ###
|
alembic/versions/73ae38d1200c_add_retrieved_doc_ids_to_messages.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add_retrieved_doc_ids_to_messages
|
| 2 |
+
|
| 3 |
+
Revision ID: 73ae38d1200c
|
| 4 |
+
Revises: e5cc44e2040a
|
| 5 |
+
Create Date: 2026-03-30 02:26:27.464417
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
from sqlalchemy.dialects import postgresql
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '73ae38d1200c'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = 'e5cc44e2040a'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.add_column('messages', sa.Column('retrieved_doc_ids', postgresql.JSONB(astext_type=sa.Text()).with_variant(sa.JSON(), 'sqlite'), nullable=True))
|
| 25 |
+
# ### end Alembic commands ###
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def downgrade() -> None:
|
| 29 |
+
"""Downgrade schema."""
|
| 30 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 31 |
+
op.drop_column('messages', 'retrieved_doc_ids')
|
| 32 |
+
# ### end Alembic commands ###
|
alembic/versions/752c239e659a_add_rest_base_to_sync_jobs_expand_.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add rest_base to sync_jobs, expand status constraint
|
| 2 |
+
|
| 3 |
+
Revision ID: 752c239e659a
|
| 4 |
+
Revises: b58e26e702df
|
| 5 |
+
Create Date: 2026-05-28 04:52:54.564900
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '752c239e659a'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = 'b58e26e702df'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
op.add_column('sync_jobs', sa.Column('rest_base', sa.String(length=50), server_default='posts', nullable=False))
|
| 24 |
+
op.drop_constraint('sync_jobs_status_valid', 'sync_jobs', type_='check')
|
| 25 |
+
op.create_check_constraint(
|
| 26 |
+
'sync_jobs_status_valid',
|
| 27 |
+
'sync_jobs',
|
| 28 |
+
"status IN ('cancelled', 'completed', 'expired', 'failed', 'paused', 'queued', 'running')",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def downgrade() -> None:
|
| 33 |
+
"""Downgrade schema."""
|
| 34 |
+
op.drop_constraint('sync_jobs_status_valid', 'sync_jobs', type_='check')
|
| 35 |
+
op.create_check_constraint(
|
| 36 |
+
'sync_jobs_status_valid',
|
| 37 |
+
'sync_jobs',
|
| 38 |
+
"status IN ('queued', 'running', 'completed', 'failed')",
|
| 39 |
+
)
|
| 40 |
+
op.drop_column('sync_jobs', 'rest_base')
|
alembic/versions/7992949ccdf5_add_industries_table.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add industries table
|
| 2 |
+
|
| 3 |
+
Revision ID: 7992949ccdf5
|
| 4 |
+
Revises: 0704837a1594
|
| 5 |
+
Create Date: 2026-04-26 01:56:13.303817
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '7992949ccdf5'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = '0704837a1594'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
industries_table = op.create_table(
|
| 23 |
+
"industries",
|
| 24 |
+
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
| 25 |
+
sa.Column("code", sa.String(length=100), nullable=False),
|
| 26 |
+
sa.Column("display_name", sa.String(length=100), nullable=False),
|
| 27 |
+
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
| 28 |
+
sa.PrimaryKeyConstraint("id", name=op.f("pk_industries")),
|
| 29 |
+
sa.UniqueConstraint("code", name=op.f("uq_industries_code")),
|
| 30 |
+
)
|
| 31 |
+
op.bulk_insert(
|
| 32 |
+
industries_table,
|
| 33 |
+
[
|
| 34 |
+
{"code": "restaurant-food-beverage", "display_name": "Restaurant / Food & Beverage", "is_active": True},
|
| 35 |
+
{"code": "retail", "display_name": "Retail", "is_active": True},
|
| 36 |
+
{"code": "e-commerce", "display_name": "E-Commerce", "is_active": True},
|
| 37 |
+
{"code": "healthcare", "display_name": "Healthcare", "is_active": True},
|
| 38 |
+
{"code": "legal", "display_name": "Legal", "is_active": True},
|
| 39 |
+
{"code": "education", "display_name": "Education", "is_active": True},
|
| 40 |
+
{"code": "real-estate", "display_name": "Real Estate", "is_active": True},
|
| 41 |
+
{"code": "financial-services", "display_name": "Financial Services", "is_active": True},
|
| 42 |
+
{"code": "technology", "display_name": "Technology", "is_active": True},
|
| 43 |
+
{"code": "travel-hospitality", "display_name": "Travel & Hospitality", "is_active": True},
|
| 44 |
+
{"code": "beauty-wellness", "display_name": "Beauty & Wellness", "is_active": True},
|
| 45 |
+
{"code": "professional-services", "display_name": "Professional Services", "is_active": True},
|
| 46 |
+
{"code": "non-profit", "display_name": "Non-Profit", "is_active": True},
|
| 47 |
+
{"code": "other", "display_name": "Other", "is_active": True},
|
| 48 |
+
# Legacy codes — kept for backward compatibility; hidden from new dropdowns
|
| 49 |
+
{"code": "saas", "display_name": "SaaS", "is_active": False},
|
| 50 |
+
{"code": "finance", "display_name": "Finance", "is_active": False},
|
| 51 |
+
],
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def downgrade() -> None:
|
| 56 |
+
op.drop_table("industries")
|
alembic/versions/7aec975a4140_credits_to_usd_refactor.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""credits to usd refactor
|
| 2 |
+
|
| 3 |
+
Revision ID: 7aec975a4140
|
| 4 |
+
Revises: ac67923a3324
|
| 5 |
+
Create Date: 2026-05-17 04:08:19.969428
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '7aec975a4140'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = 'ac67923a3324'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
op.add_column(
|
| 24 |
+
"llm_models",
|
| 25 |
+
sa.Column("usd_per_1k_input", sa.Numeric(precision=20, scale=6), nullable=True),
|
| 26 |
+
)
|
| 27 |
+
op.add_column(
|
| 28 |
+
"llm_models",
|
| 29 |
+
sa.Column("usd_per_1k_output", sa.Numeric(precision=20, scale=6), nullable=True),
|
| 30 |
+
)
|
| 31 |
+
op.add_column(
|
| 32 |
+
"tenants",
|
| 33 |
+
sa.Column("balance_usd", sa.Numeric(precision=20, scale=6), server_default="0", nullable=True),
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
op.execute(
|
| 37 |
+
"""
|
| 38 |
+
UPDATE llm_models
|
| 39 |
+
SET usd_per_1k_input = credits_per_1k_input,
|
| 40 |
+
usd_per_1k_output = credits_per_1k_output
|
| 41 |
+
WHERE usd_per_1k_input IS NULL
|
| 42 |
+
OR usd_per_1k_output IS NULL
|
| 43 |
+
"""
|
| 44 |
+
)
|
| 45 |
+
op.execute(
|
| 46 |
+
"""
|
| 47 |
+
UPDATE tenants
|
| 48 |
+
SET balance_usd = credits
|
| 49 |
+
WHERE balance_usd IS NULL
|
| 50 |
+
"""
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
op.alter_column("llm_models", "usd_per_1k_input", nullable=False)
|
| 54 |
+
op.alter_column("llm_models", "usd_per_1k_output", nullable=False)
|
| 55 |
+
op.alter_column("tenants", "balance_usd", nullable=False)
|
| 56 |
+
op.alter_column("llm_models", "credits_per_1k_input", server_default="0")
|
| 57 |
+
op.alter_column("llm_models", "credits_per_1k_output", server_default="0")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def downgrade() -> None:
|
| 61 |
+
"""Downgrade schema."""
|
| 62 |
+
op.drop_column("tenants", "balance_usd")
|
| 63 |
+
op.drop_column("llm_models", "usd_per_1k_output")
|
| 64 |
+
op.drop_column("llm_models", "usd_per_1k_input")
|
alembic/versions/84de4a13cffd_add_ai_verdict_scores_to_messages.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add_ai_verdict_scores_to_messages
|
| 2 |
+
|
| 3 |
+
Revision ID: 84de4a13cffd
|
| 4 |
+
Revises: 485fbe495e6b
|
| 5 |
+
Create Date: 2026-04-11 04:30:21.707463
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '84de4a13cffd'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = '485fbe495e6b'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.add_column('messages', sa.Column('accuracy_score', sa.Float(), nullable=True))
|
| 25 |
+
op.add_column('messages', sa.Column('relevance_score', sa.Float(), nullable=True))
|
| 26 |
+
op.add_column('messages', sa.Column('safety_score', sa.Float(), nullable=True))
|
| 27 |
+
# ### end Alembic commands ###
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def downgrade() -> None:
|
| 31 |
+
"""Downgrade schema."""
|
| 32 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 33 |
+
op.drop_column('messages', 'safety_score')
|
| 34 |
+
op.drop_column('messages', 'relevance_score')
|
| 35 |
+
op.drop_column('messages', 'accuracy_score')
|
| 36 |
+
# ### end Alembic commands ###
|
alembic/versions/9ad31e299e42_add_is_indexed_to_knowledge_entries.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add is_indexed to knowledge_entries
|
| 2 |
+
|
| 3 |
+
Revision ID: 9ad31e299e42
|
| 4 |
+
Revises: 7aec975a4140
|
| 5 |
+
Create Date: 2026-05-17 16:26:35.228332
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '9ad31e299e42'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = '7aec975a4140'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.add_column('knowledge_base', sa.Column('is_indexed', sa.Boolean(), server_default='false', nullable=False))
|
| 25 |
+
op.drop_column('llm_models', 'credits_per_1k_output')
|
| 26 |
+
op.drop_column('llm_models', 'credits_per_1k_input')
|
| 27 |
+
op.drop_column('tenants', 'credits')
|
| 28 |
+
# ### end Alembic commands ###
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def downgrade() -> None:
|
| 32 |
+
"""Downgrade schema."""
|
| 33 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 34 |
+
op.add_column('tenants', sa.Column('credits', sa.NUMERIC(precision=20, scale=6), server_default=sa.text("'0'::numeric"), autoincrement=False, nullable=False))
|
| 35 |
+
op.add_column('llm_models', sa.Column('credits_per_1k_input', sa.NUMERIC(precision=20, scale=6), server_default=sa.text("'0'::numeric"), autoincrement=False, nullable=False))
|
| 36 |
+
op.add_column('llm_models', sa.Column('credits_per_1k_output', sa.NUMERIC(precision=20, scale=6), server_default=sa.text("'0'::numeric"), autoincrement=False, nullable=False))
|
| 37 |
+
op.drop_column('knowledge_base', 'is_indexed')
|
| 38 |
+
# ### end Alembic commands ###
|
alembic/versions/9b95a5ce2ac1_add_gifted_billing_mode_check_constraint.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add gifted billing mode check constraint
|
| 2 |
+
|
| 3 |
+
Revision ID: 9b95a5ce2ac1
|
| 4 |
+
Revises: bf4016f7dd2a
|
| 5 |
+
Create Date: 2026-04-24 09:16:41.390223
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '9b95a5ce2ac1'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = 'bf4016f7dd2a'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
op.create_check_constraint(
|
| 23 |
+
"ck_tenants_chk_tenant_billing_mode",
|
| 24 |
+
"tenants",
|
| 25 |
+
"billing_mode IN ('credits', 'subscription', 'gifted')",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def downgrade() -> None:
|
| 30 |
+
op.drop_constraint("ck_tenants_chk_tenant_billing_mode", "tenants", type_="check")
|
alembic/versions/9d051350268a_seed_llm_models_initial_rates.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""seed_llm_models_initial_rates
|
| 2 |
+
|
| 3 |
+
Revision ID: 9d051350268a
|
| 4 |
+
Revises: 126f5b1610a6
|
| 5 |
+
Create Date: 2026-04-18 16:51:58.821173
|
| 6 |
+
|
| 7 |
+
Pricing basis: 2026-04-18 approximate API costs × ~3x business markup.
|
| 8 |
+
1 credit ≈ $0.001 USD. To update rates: set effective_to on old rows,
|
| 9 |
+
insert new rows — never overwrite.
|
| 10 |
+
"""
|
| 11 |
+
from typing import Sequence, Union
|
| 12 |
+
|
| 13 |
+
from alembic import op
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# revision identifiers, used by Alembic.
|
| 17 |
+
revision: str = '9d051350268a'
|
| 18 |
+
down_revision: Union[str, Sequence[str], None] = '126f5b1610a6'
|
| 19 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 20 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
_ROWS = [
|
| 24 |
+
("gemini", "gemini-2.0-flash", "0.300000", "1.200000"),
|
| 25 |
+
("groq", "qwen3-32b", "0.870000", "1.770000"),
|
| 26 |
+
("groq", "llama-3.3-70b-versatile", "1.770000", "2.370000"),
|
| 27 |
+
("anthropic", "claude-3-5-haiku-20241022", "2.400000", "12.000000"),
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def upgrade() -> None:
|
| 32 |
+
for provider, model_name, inp, out in _ROWS:
|
| 33 |
+
op.execute(
|
| 34 |
+
f"""
|
| 35 |
+
INSERT INTO llm_models
|
| 36 |
+
(provider, model_name, credits_per_1k_input, credits_per_1k_output,
|
| 37 |
+
effective_from, effective_to)
|
| 38 |
+
SELECT '{provider}', '{model_name}', {inp}, {out}, '2026-04-18', NULL
|
| 39 |
+
WHERE NOT EXISTS (
|
| 40 |
+
SELECT 1 FROM llm_models
|
| 41 |
+
WHERE provider = '{provider}'
|
| 42 |
+
AND model_name = '{model_name}'
|
| 43 |
+
AND effective_to IS NULL
|
| 44 |
+
)
|
| 45 |
+
"""
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def downgrade() -> None:
|
| 50 |
+
for provider, model_name, _, _ in _ROWS:
|
| 51 |
+
op.execute(
|
| 52 |
+
f"""
|
| 53 |
+
DELETE FROM llm_models
|
| 54 |
+
WHERE provider = '{provider}'
|
| 55 |
+
AND model_name = '{model_name}'
|
| 56 |
+
AND effective_from = '2026-04-18'
|
| 57 |
+
AND effective_to IS NULL
|
| 58 |
+
"""
|
| 59 |
+
)
|
alembic/versions/a53f4c8d9b21_kb_schema_redesign_and_sync_jobs.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""kb schema redesign and sync jobs
|
| 2 |
+
|
| 3 |
+
Revision ID: a53f4c8d9b21
|
| 4 |
+
Revises: 7992949ccdf5
|
| 5 |
+
Create Date: 2026-04-29 23:05:00.000000
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = "a53f4c8d9b21"
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = "7992949ccdf5"
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
with op.batch_alter_table("knowledge_base") as batch_op:
|
| 23 |
+
batch_op.alter_column(
|
| 24 |
+
"entry",
|
| 25 |
+
new_column_name="content",
|
| 26 |
+
existing_type=sa.Text(),
|
| 27 |
+
existing_nullable=False,
|
| 28 |
+
)
|
| 29 |
+
batch_op.add_column(sa.Column("source_url", sa.String(length=500), nullable=True))
|
| 30 |
+
batch_op.add_column(
|
| 31 |
+
sa.Column("origin", sa.String(length=20), server_default="manual", nullable=False)
|
| 32 |
+
)
|
| 33 |
+
batch_op.create_check_constraint(
|
| 34 |
+
"ck_knowledge_base_kb_origin_valid",
|
| 35 |
+
"origin IN ('manual', 'synced')",
|
| 36 |
+
)
|
| 37 |
+
batch_op.drop_column("tag")
|
| 38 |
+
batch_op.drop_column("category")
|
| 39 |
+
|
| 40 |
+
inspector = sa.inspect(op.get_bind())
|
| 41 |
+
if not inspector.has_table("sync_jobs"):
|
| 42 |
+
op.create_table(
|
| 43 |
+
"sync_jobs",
|
| 44 |
+
sa.Column("id", sa.UUID(), nullable=False),
|
| 45 |
+
sa.Column("created_at", sa.DateTime(), nullable=True),
|
| 46 |
+
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
| 47 |
+
sa.Column("tenant_id", sa.UUID(), nullable=False),
|
| 48 |
+
sa.Column("source_url", sa.String(length=500), nullable=False),
|
| 49 |
+
sa.Column("status", sa.String(length=30), server_default="queued", nullable=False),
|
| 50 |
+
sa.Column("total", sa.Integer(), server_default="0", nullable=False),
|
| 51 |
+
sa.Column("processed", sa.Integer(), server_default="0", nullable=False),
|
| 52 |
+
sa.Column("failed", sa.Integer(), server_default="0", nullable=False),
|
| 53 |
+
sa.Column("error_message", sa.Text(), nullable=True),
|
| 54 |
+
sa.CheckConstraint(
|
| 55 |
+
"failed >= 0",
|
| 56 |
+
name=op.f("ck_sync_jobs_sync_jobs_failed_nonnegative"),
|
| 57 |
+
),
|
| 58 |
+
sa.CheckConstraint(
|
| 59 |
+
"processed >= 0",
|
| 60 |
+
name=op.f("ck_sync_jobs_sync_jobs_processed_nonnegative"),
|
| 61 |
+
),
|
| 62 |
+
sa.CheckConstraint(
|
| 63 |
+
"processed + failed <= total",
|
| 64 |
+
name=op.f("ck_sync_jobs_sync_jobs_progress_within_total"),
|
| 65 |
+
),
|
| 66 |
+
sa.CheckConstraint(
|
| 67 |
+
"status IN ('queued', 'running', 'completed', 'failed')",
|
| 68 |
+
name=op.f("ck_sync_jobs_sync_jobs_status_valid"),
|
| 69 |
+
),
|
| 70 |
+
sa.CheckConstraint(
|
| 71 |
+
"total >= 0",
|
| 72 |
+
name=op.f("ck_sync_jobs_sync_jobs_total_nonnegative"),
|
| 73 |
+
),
|
| 74 |
+
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_sync_jobs_tenant_id_tenants")),
|
| 75 |
+
sa.PrimaryKeyConstraint("id", name=op.f("pk_sync_jobs")),
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def downgrade() -> None:
|
| 80 |
+
op.drop_table("sync_jobs")
|
| 81 |
+
|
| 82 |
+
with op.batch_alter_table("knowledge_base") as batch_op:
|
| 83 |
+
batch_op.add_column(
|
| 84 |
+
sa.Column("category", sa.String(length=100), server_default="Uncategorized", nullable=False)
|
| 85 |
+
)
|
| 86 |
+
batch_op.add_column(sa.Column("tag", sa.String(length=100), nullable=True))
|
| 87 |
+
batch_op.drop_constraint("ck_knowledge_base_kb_origin_valid", type_="check")
|
| 88 |
+
batch_op.drop_column("origin")
|
| 89 |
+
batch_op.drop_column("source_url")
|
| 90 |
+
batch_op.alter_column(
|
| 91 |
+
"content",
|
| 92 |
+
new_column_name="entry",
|
| 93 |
+
existing_type=sa.Text(),
|
| 94 |
+
existing_nullable=False,
|
| 95 |
+
)
|
alembic/versions/ac67923a3324_add_created_updated_skipped_to_sync_jobs.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add created updated skipped to sync_jobs
|
| 2 |
+
|
| 3 |
+
Revision ID: ac67923a3324
|
| 4 |
+
Revises: a53f4c8d9b21
|
| 5 |
+
Create Date: 2026-05-02 15:38:19.664806
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = 'ac67923a3324'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = 'a53f4c8d9b21'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.add_column('sync_jobs', sa.Column('created', sa.Integer(), server_default='0', nullable=False))
|
| 25 |
+
op.add_column('sync_jobs', sa.Column('updated', sa.Integer(), server_default='0', nullable=False))
|
| 26 |
+
op.add_column('sync_jobs', sa.Column('skipped', sa.Integer(), server_default='0', nullable=False))
|
| 27 |
+
# ### end Alembic commands ###
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def downgrade() -> None:
|
| 31 |
+
"""Downgrade schema."""
|
| 32 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 33 |
+
op.drop_column('sync_jobs', 'skipped')
|
| 34 |
+
op.drop_column('sync_jobs', 'updated')
|
| 35 |
+
op.drop_column('sync_jobs', 'created')
|
| 36 |
+
# ### end Alembic commands ###
|
alembic/versions/b58e26e702df_add_is_blocked_to_tenants_and_note_.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add is_blocked to tenants and note created_by to credit_events
|
| 2 |
+
|
| 3 |
+
Revision ID: b58e26e702df
|
| 4 |
+
Revises: 9ad31e299e42
|
| 5 |
+
Create Date: 2026-05-27 16:58:30.128906
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = 'b58e26e702df'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = '9ad31e299e42'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.add_column('credit_events', sa.Column('note', sa.Text(), nullable=True))
|
| 25 |
+
op.add_column('credit_events', sa.Column('created_by', sa.String(length=100), nullable=True))
|
| 26 |
+
op.add_column('tenants', sa.Column('is_blocked', sa.Boolean(), server_default='false', nullable=False))
|
| 27 |
+
# ### end Alembic commands ###
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def downgrade() -> None:
|
| 31 |
+
"""Downgrade schema."""
|
| 32 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 33 |
+
op.drop_column('tenants', 'is_blocked')
|
| 34 |
+
op.drop_column('credit_events', 'created_by')
|
| 35 |
+
op.drop_column('credit_events', 'note')
|
| 36 |
+
# ### end Alembic commands ###
|
alembic/versions/bf4016f7dd2a_add_website_url_to_tenants.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add website_url to tenants
|
| 2 |
+
|
| 3 |
+
Revision ID: bf4016f7dd2a
|
| 4 |
+
Revises: 3d1418b32af0
|
| 5 |
+
Create Date: 2026-04-24 08:49:11.717539
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = 'bf4016f7dd2a'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = '3d1418b32af0'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.add_column('tenants', sa.Column('website_url', sa.String(length=255), nullable=True))
|
| 25 |
+
# ### end Alembic commands ###
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def downgrade() -> None:
|
| 29 |
+
"""Downgrade schema."""
|
| 30 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 31 |
+
op.drop_column('tenants', 'website_url')
|
| 32 |
+
# ### end Alembic commands ###
|
alembic/versions/c9e1f8a2b7d4_add_auth_fields_to_tenants.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add_auth_fields_to_tenants
|
| 2 |
+
|
| 3 |
+
Revision ID: c9e1f8a2b7d4
|
| 4 |
+
Revises: 84de4a13cffd
|
| 5 |
+
Create Date: 2026-04-17 00:00:00.000000
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = 'c9e1f8a2b7d4'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = '84de4a13cffd'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
op.add_column('tenants', sa.Column('owner_email', sa.String(255), nullable=True))
|
| 24 |
+
op.add_column('tenants', sa.Column('google_id', sa.String(255), nullable=True))
|
| 25 |
+
op.add_column('tenants', sa.Column('totp_secret', sa.String(512), nullable=True))
|
| 26 |
+
op.add_column('tenants', sa.Column('totp_enabled', sa.Boolean(), nullable=True))
|
| 27 |
+
|
| 28 |
+
op.create_unique_constraint('uq_tenants_owner_email', 'tenants', ['owner_email'])
|
| 29 |
+
op.create_unique_constraint('uq_tenants_google_id', 'tenants', ['google_id'])
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def downgrade() -> None:
|
| 33 |
+
"""Downgrade schema."""
|
| 34 |
+
op.drop_constraint('uq_tenants_google_id', 'tenants', type_='unique')
|
| 35 |
+
op.drop_constraint('uq_tenants_owner_email', 'tenants', type_='unique')
|
| 36 |
+
op.drop_column('tenants', 'totp_enabled')
|
| 37 |
+
op.drop_column('tenants', 'totp_secret')
|
| 38 |
+
op.drop_column('tenants', 'google_id')
|
| 39 |
+
op.drop_column('tenants', 'owner_email')
|
alembic/versions/d1e126fba5fb_add_title_category_tag_to_kb_entries.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add title category tag to kb entries
|
| 2 |
+
|
| 3 |
+
Revision ID: d1e126fba5fb
|
| 4 |
+
Revises: 9b95a5ce2ac1
|
| 5 |
+
Create Date: 2026-04-24 09:32:20.768068
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = 'd1e126fba5fb'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = '9b95a5ce2ac1'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.add_column('knowledge_base', sa.Column('title', sa.String(length=255), server_default='Untitled', nullable=False))
|
| 25 |
+
op.add_column('knowledge_base', sa.Column('category', sa.String(length=100), server_default='Uncategorized', nullable=False))
|
| 26 |
+
op.add_column('knowledge_base', sa.Column('tag', sa.String(length=100), nullable=True))
|
| 27 |
+
# ### end Alembic commands ###
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def downgrade() -> None:
|
| 31 |
+
"""Downgrade schema."""
|
| 32 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 33 |
+
op.drop_column('knowledge_base', 'tag')
|
| 34 |
+
op.drop_column('knowledge_base', 'category')
|
| 35 |
+
op.drop_column('knowledge_base', 'title')
|
| 36 |
+
# ### end Alembic commands ###
|
alembic/versions/d2d812e2c226_add_last_message_preview_to_.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add_last_message_preview_to_conversations
|
| 2 |
+
|
| 3 |
+
Revision ID: d2d812e2c226
|
| 4 |
+
Revises: 73ae38d1200c
|
| 5 |
+
Create Date: 2026-03-30 10:33:10.409942
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = 'd2d812e2c226'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = '73ae38d1200c'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.add_column('conversations', sa.Column('last_message_preview', sa.String(length=255), nullable=True))
|
| 25 |
+
# ### end Alembic commands ###
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def downgrade() -> None:
|
| 29 |
+
"""Downgrade schema."""
|
| 30 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 31 |
+
op.drop_column('conversations', 'last_message_preview')
|
| 32 |
+
# ### end Alembic commands ###
|
alembic/versions/d7470fcd70c6_add_backup_codes_to_tenants.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add_backup_codes_to_tenants
|
| 2 |
+
|
| 3 |
+
Revision ID: d7470fcd70c6
|
| 4 |
+
Revises: c9e1f8a2b7d4
|
| 5 |
+
Create Date: 2026-04-17 15:21:51.999768
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = 'd7470fcd70c6'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = 'c9e1f8a2b7d4'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.add_column('tenants', sa.Column('backup_codes', sa.Text(), nullable=True))
|
| 25 |
+
# ### end Alembic commands ###
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def downgrade() -> None:
|
| 29 |
+
"""Downgrade schema."""
|
| 30 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 31 |
+
op.drop_column('tenants', 'backup_codes')
|
| 32 |
+
# ### end Alembic commands ###
|
alembic/versions/e5cc44e2040a_make_tenant_id_non_nullable_on_users_.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""make tenant_id non-nullable on users; change entities to JSON on messages
|
| 2 |
+
|
| 3 |
+
Revision ID: e5cc44e2040a
|
| 4 |
+
Revises: f6988c25696e
|
| 5 |
+
Create Date: 2026-03-27 15:22:44.809002
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
from sqlalchemy.dialects import postgresql
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = 'e5cc44e2040a'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = 'f6988c25696e'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.execute("ALTER TABLE messages ALTER COLUMN entities TYPE JSONB USING entities::jsonb")
|
| 25 |
+
op.execute("DELETE FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE user_id IN (SELECT id FROM users WHERE tenant_id IS NULL))")
|
| 26 |
+
op.execute("DELETE FROM conversations WHERE user_id IN (SELECT id FROM users WHERE tenant_id IS NULL)")
|
| 27 |
+
op.execute("DELETE FROM users WHERE tenant_id IS NULL")
|
| 28 |
+
op.alter_column('users', 'tenant_id',
|
| 29 |
+
existing_type=sa.UUID(),
|
| 30 |
+
nullable=False)
|
| 31 |
+
# ### end Alembic commands ###
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def downgrade() -> None:
|
| 35 |
+
"""Downgrade schema."""
|
| 36 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 37 |
+
op.alter_column('users', 'tenant_id',
|
| 38 |
+
existing_type=sa.UUID(),
|
| 39 |
+
nullable=True)
|
| 40 |
+
op.alter_column('messages', 'entities',
|
| 41 |
+
existing_type=postgresql.JSONB(astext_type=sa.Text()),
|
| 42 |
+
type_=sa.TEXT(),
|
| 43 |
+
existing_nullable=True)
|
| 44 |
+
# ### end Alembic commands ###
|
alembic/versions/f6988c25696e_add_customization_to_tenants.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add_customization_to_tenants
|
| 2 |
+
|
| 3 |
+
Revision ID: f6988c25696e
|
| 4 |
+
Revises: 5f697ba37cb7
|
| 5 |
+
Create Date: 2026-03-24 23:33:21.014824
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
from sqlalchemy.dialects import postgresql
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# revision identifiers, used by Alembic.
|
| 16 |
+
revision: str = 'f6988c25696e'
|
| 17 |
+
down_revision: Union[str, Sequence[str], None] = '5f697ba37cb7'
|
| 18 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 19 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def upgrade() -> None:
|
| 23 |
+
"""Upgrade schema."""
|
| 24 |
+
op.add_column("tenants", sa.Column("customization", postgresql.JSONB(), nullable=True))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def downgrade() -> None:
|
| 28 |
+
"""Downgrade schema."""
|
| 29 |
+
op.drop_column("tenants", "customization")
|
app/dependencies/__init__.py
ADDED
|
File without changes
|
app/dependencies/admin_auth.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
|
| 3 |
+
from fastapi import HTTPException, Request
|
| 4 |
+
|
| 5 |
+
from app.services.google_auth import decode_full_jwt
|
| 6 |
+
from app.utils.config import get_config
|
| 7 |
+
|
| 8 |
+
config = get_config()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def require_admin_jwt(request: Request) -> uuid.UUID:
|
| 12 |
+
token = request.cookies.get("access_token")
|
| 13 |
+
if not token:
|
| 14 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
tenant_id_str = decode_full_jwt(token, config.jwt_secret_key)
|
| 18 |
+
tenant_id = uuid.UUID(tenant_id_str)
|
| 19 |
+
except (ValueError, AttributeError):
|
| 20 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 21 |
+
|
| 22 |
+
request.state.tenant_id = tenant_id
|
| 23 |
+
return tenant_id
|
app/dependencies/super_admin_auth.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import NoReturn
|
| 2 |
+
|
| 3 |
+
from fastapi import HTTPException, Request
|
| 4 |
+
|
| 5 |
+
from app.services.google_auth import decode_super_admin_jwt
|
| 6 |
+
from app.utils.config import get_config
|
| 7 |
+
|
| 8 |
+
config = get_config()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def require_super_admin_jwt(request: Request) -> str:
|
| 12 |
+
def _reject() -> NoReturn:
|
| 13 |
+
raise HTTPException(status_code=403, detail="Forbidden")
|
| 14 |
+
|
| 15 |
+
token = request.cookies.get("super_admin_token")
|
| 16 |
+
if not token:
|
| 17 |
+
_reject()
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
email = decode_super_admin_jwt(token, config.jwt_secret_key)
|
| 21 |
+
except ValueError:
|
| 22 |
+
_reject()
|
| 23 |
+
|
| 24 |
+
if email.lower() not in [e.lower() for e in config.super_admin_emails]:
|
| 25 |
+
_reject()
|
| 26 |
+
|
| 27 |
+
return email
|
app/main.py
CHANGED
|
@@ -1,10 +1,15 @@
|
|
| 1 |
# app/main.py
|
| 2 |
# pylint: disable=unused-import
|
|
|
|
|
|
|
| 3 |
import uuid
|
| 4 |
import time
|
|
|
|
| 5 |
from contextlib import asynccontextmanager
|
| 6 |
from pathlib import Path
|
| 7 |
-
from fastapi import FastAPI, HTTPException
|
|
|
|
|
|
|
| 8 |
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
from fastapi.staticfiles import StaticFiles
|
| 10 |
from pydantic import BaseModel
|
|
@@ -12,27 +17,68 @@ from pydantic import BaseModel
|
|
| 12 |
# NOTE: Using absolute imports from project root (app.*) instead of relative imports
|
| 13 |
# This ensures imports work consistently in both IDE and Docker environments
|
| 14 |
from app.middleware.tenant_auth import TenantAuthMiddleware
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from app.utils.config import get_config, get_logger, get_version
|
| 16 |
from app.models.nlp_engine import NLPEngine
|
| 17 |
from app.models.conversation_manager import ConversationManager
|
| 18 |
from app.models.database import (
|
|
|
|
| 19 |
SessionLocal,
|
|
|
|
|
|
|
| 20 |
init_database,
|
| 21 |
health_check as db_health_check,
|
| 22 |
)
|
| 23 |
-
from app.models.smart_models import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
from app.data.training_data import TRAINING_DATA
|
| 25 |
-
from app.routers import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
from app.utils.seeding import seed_demo_tenant
|
|
|
|
|
|
|
| 27 |
|
| 28 |
# Load configuration (this sets up logging automatically)
|
| 29 |
config = get_config()
|
| 30 |
logger = get_logger(__name__)
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
# Train the mode on startup
|
| 34 |
@asynccontextmanager
|
| 35 |
-
async def lifespan(
|
|
|
|
|
|
|
| 36 |
"""Application lifespan manager for startup and shutdown events"""
|
| 37 |
# Startup
|
| 38 |
logger.info("Starting application..")
|
|
@@ -42,7 +88,8 @@ async def lifespan(fastapi_app: FastAPI): # pylint: disable=unused-argument
|
|
| 42 |
try:
|
| 43 |
init_database()
|
| 44 |
with SessionLocal() as session:
|
| 45 |
-
|
|
|
|
| 46 |
|
| 47 |
if db_health_check():
|
| 48 |
logger.info("Database connection established successfully")
|
|
@@ -52,9 +99,25 @@ async def lifespan(fastapi_app: FastAPI): # pylint: disable=unused-argument
|
|
| 52 |
logger.error("Database initialization failed: %s: %s", type(e).__name__, str(e))
|
| 53 |
logger.warning("Continuing without database - some features may not work...")
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
# Train NLP model
|
| 56 |
nlp_engine.train_intent_classifier(TRAINING_DATA)
|
| 57 |
logger.info("NLP model training completed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
logger.info("Application started successfully in %s mode", config.env.name)
|
| 59 |
|
| 60 |
yield # App runs here
|
|
@@ -64,7 +127,20 @@ async def lifespan(fastapi_app: FastAPI): # pylint: disable=unused-argument
|
|
| 64 |
|
| 65 |
|
| 66 |
app = FastAPI(title=config.api.title, debug=config.api.debug, lifespan=lifespan)
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
# Enable CORS
|
| 70 |
app.add_middleware(
|
|
@@ -115,34 +191,51 @@ async def health_check():
|
|
| 115 |
# Check database health
|
| 116 |
db_status = "healthy" if db_health_check() else "unhealthy"
|
| 117 |
|
|
|
|
|
|
|
| 118 |
return {
|
| 119 |
-
"status":
|
| 120 |
"environment": config.env.name,
|
| 121 |
"version": get_version(), # Read from pyproject.toml
|
| 122 |
"database": db_status,
|
|
|
|
| 123 |
"components": {
|
| 124 |
"nlp_engine": "ready",
|
| 125 |
"conversation_manager": "ready",
|
| 126 |
"database": db_status,
|
|
|
|
| 127 |
},
|
| 128 |
}
|
| 129 |
|
| 130 |
|
| 131 |
-
@
|
| 132 |
-
async def chat(
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
start_time = time.time()
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
try:
|
|
|
|
|
|
|
| 137 |
# Generate session ID if not provided
|
| 138 |
-
session_id =
|
| 139 |
|
| 140 |
# Detect language
|
| 141 |
-
language = nlp_engine.detect_language(
|
| 142 |
|
| 143 |
# Process message
|
| 144 |
-
intent, confidence = nlp_engine.classify_intent(
|
| 145 |
-
entities = nlp_engine.extract_entities(
|
| 146 |
|
| 147 |
# Convert NumPy types to Python types
|
| 148 |
intent = str(intent)
|
|
@@ -164,26 +257,107 @@ async def chat(request: ChatRequest):
|
|
| 164 |
)
|
| 165 |
intent = "low_confidence"
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
# Generate response (now with context awareness)
|
| 168 |
-
thinking_block, response = conversation_manager.get_response(
|
| 169 |
-
intent,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
)
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
# Calculate response time
|
| 173 |
response_time_ms = int((time.time() - start_time) * 1000)
|
| 174 |
|
| 175 |
# Save conversation (now to database)
|
| 176 |
-
conversation_manager.save_conversation(
|
| 177 |
-
session_id,
|
| 178 |
-
|
| 179 |
-
response,
|
| 180 |
-
intent,
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
| 185 |
)
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
# Prepare response
|
| 188 |
chat_response = ChatResponse(
|
| 189 |
thinking=thinking_block,
|
|
@@ -208,12 +382,24 @@ async def chat(request: ChatRequest):
|
|
| 208 |
logger.info("Chat response generated successfully for session %s", session_id)
|
| 209 |
return chat_response
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
except Exception as e:
|
| 212 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
raise HTTPException(status_code=500, detail="Internal server error") from e
|
| 214 |
|
| 215 |
|
| 216 |
-
@
|
| 217 |
async def get_conversation_history(session_id: str):
|
| 218 |
logger.debug("Analytics requested for session: %s", session_id)
|
| 219 |
|
|
@@ -237,7 +423,7 @@ async def get_conversation_history(session_id: str):
|
|
| 237 |
raise HTTPException(status_code=500, detail="Internal server error") from e
|
| 238 |
|
| 239 |
|
| 240 |
-
@
|
| 241 |
async def get_user_stats(session_id: str):
|
| 242 |
logger.debug("User stats requested for session: %s", session_id)
|
| 243 |
|
|
@@ -257,6 +443,8 @@ async def get_user_stats(session_id: str):
|
|
| 257 |
raise HTTPException(status_code=500, detail="Internal server error") from e
|
| 258 |
|
| 259 |
|
|
|
|
|
|
|
| 260 |
# Serve frontend static files (HF Spaces has no separate nginx)
|
| 261 |
# Must be mounted LAST so API routes above take precedence
|
| 262 |
_frontend_dir = Path(__file__).parent.parent / "frontend"
|
|
|
|
| 1 |
# app/main.py
|
| 2 |
# pylint: disable=unused-import
|
| 3 |
+
from decimal import Decimal
|
| 4 |
+
from typing import Optional
|
| 5 |
import uuid
|
| 6 |
import time
|
| 7 |
+
from collections.abc import AsyncIterator
|
| 8 |
from contextlib import asynccontextmanager
|
| 9 |
from pathlib import Path
|
| 10 |
+
from fastapi import APIRouter, BackgroundTasks, Depends, FastAPI, HTTPException, Request
|
| 11 |
+
from sqlalchemy import select
|
| 12 |
+
from sqlalchemy.orm import Session
|
| 13 |
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
from fastapi.staticfiles import StaticFiles
|
| 15 |
from pydantic import BaseModel
|
|
|
|
| 17 |
# NOTE: Using absolute imports from project root (app.*) instead of relative imports
|
| 18 |
# This ensures imports work consistently in both IDE and Docker environments
|
| 19 |
from app.middleware.tenant_auth import TenantAuthMiddleware
|
| 20 |
+
from app.services.credit_service import (
|
| 21 |
+
CreditService,
|
| 22 |
+
InsufficientCreditsError,
|
| 23 |
+
ModelRateNotFoundError,
|
| 24 |
+
)
|
| 25 |
+
from app.services.dependencies import (
|
| 26 |
+
get_embedding_service,
|
| 27 |
+
get_retrieval_service,
|
| 28 |
+
get_vector_store,
|
| 29 |
+
)
|
| 30 |
+
from app.services.retrieval_service import RetrievalService
|
| 31 |
from app.utils.config import get_config, get_logger, get_version
|
| 32 |
from app.models.nlp_engine import NLPEngine
|
| 33 |
from app.models.conversation_manager import ConversationManager
|
| 34 |
from app.models.database import (
|
| 35 |
+
LLMModel,
|
| 36 |
SessionLocal,
|
| 37 |
+
Tenant,
|
| 38 |
+
get_db,
|
| 39 |
init_database,
|
| 40 |
health_check as db_health_check,
|
| 41 |
)
|
| 42 |
+
from app.models.smart_models import (
|
| 43 |
+
UserPreference,
|
| 44 |
+
UserInsight,
|
| 45 |
+
ConversationTopic,
|
| 46 |
+
) # this ensures Base in database.py imports these tables
|
| 47 |
from app.data.training_data import TRAINING_DATA
|
| 48 |
+
from app.routers import (
|
| 49 |
+
admin_attention,
|
| 50 |
+
admin_auth,
|
| 51 |
+
admin_apikey,
|
| 52 |
+
admin_me,
|
| 53 |
+
admin_settings,
|
| 54 |
+
billing,
|
| 55 |
+
conversations,
|
| 56 |
+
knowledge_base,
|
| 57 |
+
super_admin_auth,
|
| 58 |
+
super_admin_tenants,
|
| 59 |
+
super_admin_rates,
|
| 60 |
+
widget_settings,
|
| 61 |
+
system_prompt as system_prompt_router,
|
| 62 |
+
)
|
| 63 |
from app.utils.seeding import seed_demo_tenant
|
| 64 |
+
from app.utils.token_counter import InputTooLargeError
|
| 65 |
+
from app.services.ai_verdict import run_ai_verdict
|
| 66 |
|
| 67 |
# Load configuration (this sets up logging automatically)
|
| 68 |
config = get_config()
|
| 69 |
logger = get_logger(__name__)
|
| 70 |
|
| 71 |
+
API_V1_PREFIX = f"/api/{config.api_version}"
|
| 72 |
+
api_router = APIRouter(prefix=API_V1_PREFIX)
|
| 73 |
+
|
| 74 |
+
_qdrant_ready: bool = False
|
| 75 |
+
|
| 76 |
|
| 77 |
# Train the mode on startup
|
| 78 |
@asynccontextmanager
|
| 79 |
+
async def lifespan(
|
| 80 |
+
fastapi_app: FastAPI,
|
| 81 |
+
) -> AsyncIterator[None]: # pylint: disable=unused-argument
|
| 82 |
"""Application lifespan manager for startup and shutdown events"""
|
| 83 |
# Startup
|
| 84 |
logger.info("Starting application..")
|
|
|
|
| 88 |
try:
|
| 89 |
init_database()
|
| 90 |
with SessionLocal() as session:
|
| 91 |
+
if config.env.name == "development":
|
| 92 |
+
seed_demo_tenant(session)
|
| 93 |
|
| 94 |
if db_health_check():
|
| 95 |
logger.info("Database connection established successfully")
|
|
|
|
| 99 |
logger.error("Database initialization failed: %s: %s", type(e).__name__, str(e))
|
| 100 |
logger.warning("Continuing without database - some features may not work...")
|
| 101 |
|
| 102 |
+
global _qdrant_ready
|
| 103 |
+
try:
|
| 104 |
+
get_vector_store().health_check()
|
| 105 |
+
_qdrant_ready = True
|
| 106 |
+
logger.info("Vector store readiness check passed")
|
| 107 |
+
except Exception as e: # pylint: disable=broad-exception-caught
|
| 108 |
+
_qdrant_ready = False
|
| 109 |
+
logger.error(
|
| 110 |
+
"Vector store readiness check failed: %s — starting in degraded mode", str(e)
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
# Train NLP model
|
| 114 |
nlp_engine.train_intent_classifier(TRAINING_DATA)
|
| 115 |
logger.info("NLP model training completed")
|
| 116 |
+
|
| 117 |
+
# Remote Gemini embeddings do not require a local model warmup call.
|
| 118 |
+
get_embedding_service()
|
| 119 |
+
logger.info("Embedding service initialized successfully")
|
| 120 |
+
|
| 121 |
logger.info("Application started successfully in %s mode", config.env.name)
|
| 122 |
|
| 123 |
yield # App runs here
|
|
|
|
| 127 |
|
| 128 |
|
| 129 |
app = FastAPI(title=config.api.title, debug=config.api.debug, lifespan=lifespan)
|
| 130 |
+
|
| 131 |
+
api_router.include_router(admin_attention.router)
|
| 132 |
+
api_router.include_router(admin_auth.router)
|
| 133 |
+
api_router.include_router(admin_apikey.router)
|
| 134 |
+
api_router.include_router(admin_me.router)
|
| 135 |
+
api_router.include_router(admin_settings.router)
|
| 136 |
+
api_router.include_router(knowledge_base.router)
|
| 137 |
+
api_router.include_router(billing.router)
|
| 138 |
+
api_router.include_router(widget_settings.router)
|
| 139 |
+
api_router.include_router(system_prompt_router.router)
|
| 140 |
+
api_router.include_router(conversations.router)
|
| 141 |
+
api_router.include_router(super_admin_auth.router)
|
| 142 |
+
api_router.include_router(super_admin_tenants.router)
|
| 143 |
+
api_router.include_router(super_admin_rates.router)
|
| 144 |
|
| 145 |
# Enable CORS
|
| 146 |
app.add_middleware(
|
|
|
|
| 191 |
# Check database health
|
| 192 |
db_status = "healthy" if db_health_check() else "unhealthy"
|
| 193 |
|
| 194 |
+
qdrant_status = "healthy" if _qdrant_ready else "degraded"
|
| 195 |
+
overall_status = "healthy" if (db_status == "healthy" and _qdrant_ready) else "degraded"
|
| 196 |
return {
|
| 197 |
+
"status": overall_status,
|
| 198 |
"environment": config.env.name,
|
| 199 |
"version": get_version(), # Read from pyproject.toml
|
| 200 |
"database": db_status,
|
| 201 |
+
"qdrant": qdrant_status,
|
| 202 |
"components": {
|
| 203 |
"nlp_engine": "ready",
|
| 204 |
"conversation_manager": "ready",
|
| 205 |
"database": db_status,
|
| 206 |
+
"qdrant": qdrant_status,
|
| 207 |
},
|
| 208 |
}
|
| 209 |
|
| 210 |
|
| 211 |
+
@api_router.post("/chat", response_model=ChatResponse)
|
| 212 |
+
async def chat(
|
| 213 |
+
request: Request,
|
| 214 |
+
body: ChatRequest,
|
| 215 |
+
background_tasks: BackgroundTasks,
|
| 216 |
+
retrieval_service: RetrievalService = Depends(get_retrieval_service),
|
| 217 |
+
db: Session = Depends(get_db),
|
| 218 |
+
) -> ChatResponse:
|
| 219 |
+
logger.debug("Chat request received: %s ...", body.message[:50])
|
| 220 |
start_time = time.time()
|
| 221 |
|
| 222 |
+
# Get tenant_id from the TenantAuthMiddleware
|
| 223 |
+
tenant_id = request.state.tenant_id
|
| 224 |
+
|
| 225 |
+
request_id = str(uuid.uuid4())
|
| 226 |
+
|
| 227 |
try:
|
| 228 |
+
credit_service = CreditService()
|
| 229 |
+
|
| 230 |
# Generate session ID if not provided
|
| 231 |
+
session_id = body.session_id or str(uuid.uuid4())
|
| 232 |
|
| 233 |
# Detect language
|
| 234 |
+
language = nlp_engine.detect_language(body.message)
|
| 235 |
|
| 236 |
# Process message
|
| 237 |
+
intent, confidence = nlp_engine.classify_intent(body.message)
|
| 238 |
+
entities = nlp_engine.extract_entities(body.message, language)
|
| 239 |
|
| 240 |
# Convert NumPy types to Python types
|
| 241 |
intent = str(intent)
|
|
|
|
| 257 |
)
|
| 258 |
intent = "low_confidence"
|
| 259 |
|
| 260 |
+
# Fetch tenant (used for system prompt and billing)
|
| 261 |
+
tenant = None
|
| 262 |
+
try:
|
| 263 |
+
tenant = db.get(Tenant, tenant_id)
|
| 264 |
+
except Exception: # pylint: disable=broad-exception-caught
|
| 265 |
+
pass
|
| 266 |
+
tenant_system_prompt = (
|
| 267 |
+
(tenant.customization or {}).get("system_prompt") if tenant else None
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
if tenant and tenant.billing_mode == "credits":
|
| 271 |
+
try:
|
| 272 |
+
reserve_cost = credit_service.estimate_max_chat_cost(
|
| 273 |
+
model_refs=[
|
| 274 |
+
config.chat_model_primary,
|
| 275 |
+
*config.chat_model_fallbacks,
|
| 276 |
+
],
|
| 277 |
+
max_input_tokens=config.nlp["max_input_tokens"],
|
| 278 |
+
max_output_tokens=config.chat_max_output_tokens,
|
| 279 |
+
db=db,
|
| 280 |
+
)
|
| 281 |
+
except ModelRateNotFoundError:
|
| 282 |
+
raise HTTPException(status_code=500, detail="LLM model rate not found")
|
| 283 |
+
|
| 284 |
+
if tenant.balance_usd < reserve_cost:
|
| 285 |
+
raise HTTPException(status_code=402, detail="Insufficient credits")
|
| 286 |
+
|
| 287 |
+
retrieved_docs, retrieved_ids = retrieval_service.retrieve(
|
| 288 |
+
tenant_id=str(tenant_id),
|
| 289 |
+
query=body.message,
|
| 290 |
+
n_results=config.rag_n_results,
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
# Generate response (now with context awareness)
|
| 294 |
+
thinking_block, response, should_summarize, llm_result = conversation_manager.get_response(
|
| 295 |
+
intent=intent,
|
| 296 |
+
language=language,
|
| 297 |
+
user_input=body.message,
|
| 298 |
+
tenant_id=tenant_id,
|
| 299 |
+
retrieved_docs=retrieved_docs,
|
| 300 |
+
session_id=session_id,
|
| 301 |
+
entities=entities,
|
| 302 |
+
system_prompt=tenant_system_prompt,
|
| 303 |
)
|
| 304 |
|
| 305 |
+
# get_response() returns str | None; narrow to str before passing downstream
|
| 306 |
+
response = response or "I'm sorry, I couldn't generate a response."
|
| 307 |
+
|
| 308 |
+
# Billing: deduct credits before saving the conversation
|
| 309 |
+
if tenant and tenant.billing_mode == "credits":
|
| 310 |
+
try:
|
| 311 |
+
llm_model = credit_service.get_llm_model_for_response(
|
| 312 |
+
llm_result.model_used,
|
| 313 |
+
db,
|
| 314 |
+
)
|
| 315 |
+
except ModelRateNotFoundError:
|
| 316 |
+
raise HTTPException(status_code=500, detail="LLM model rate not found")
|
| 317 |
+
|
| 318 |
+
amount = credit_service.calculate_chat_cost(
|
| 319 |
+
input_tokens=llm_result.input_tokens,
|
| 320 |
+
output_tokens=llm_result.output_tokens,
|
| 321 |
+
llm_model=llm_model,
|
| 322 |
+
)
|
| 323 |
+
try:
|
| 324 |
+
credit_service.deduct_for_chat(
|
| 325 |
+
tenant_id,
|
| 326 |
+
amount,
|
| 327 |
+
request_id,
|
| 328 |
+
llm_model.id,
|
| 329 |
+
db,
|
| 330 |
+
)
|
| 331 |
+
except InsufficientCreditsError:
|
| 332 |
+
raise HTTPException(status_code=402, detail="Insufficient credits")
|
| 333 |
+
|
| 334 |
# Calculate response time
|
| 335 |
response_time_ms = int((time.time() - start_time) * 1000)
|
| 336 |
|
| 337 |
# Save conversation (now to database)
|
| 338 |
+
message_id = conversation_manager.save_conversation(
|
| 339 |
+
session_id=session_id,
|
| 340 |
+
user_input=body.message,
|
| 341 |
+
bot_response=response,
|
| 342 |
+
intent=intent,
|
| 343 |
+
tenant_id=tenant_id,
|
| 344 |
+
confidence=confidence,
|
| 345 |
+
entities=entities,
|
| 346 |
+
language=language,
|
| 347 |
+
response_time_ms=response_time_ms,
|
| 348 |
+
retrieved_doc_ids=retrieved_ids,
|
| 349 |
+
should_summarize=should_summarize,
|
| 350 |
)
|
| 351 |
|
| 352 |
+
if message_id:
|
| 353 |
+
background_tasks.add_task(
|
| 354 |
+
run_ai_verdict,
|
| 355 |
+
message_id=message_id,
|
| 356 |
+
user_query=body.message,
|
| 357 |
+
retrieved_docs=retrieved_docs,
|
| 358 |
+
bot_response=response,
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
# Prepare response
|
| 362 |
chat_response = ChatResponse(
|
| 363 |
thinking=thinking_block,
|
|
|
|
| 382 |
logger.info("Chat response generated successfully for session %s", session_id)
|
| 383 |
return chat_response
|
| 384 |
|
| 385 |
+
except HTTPException:
|
| 386 |
+
raise
|
| 387 |
+
|
| 388 |
+
except InputTooLargeError as e:
|
| 389 |
+
logger.error("Input Too Large error: %s: %s", type(e).__name__, str(e))
|
| 390 |
+
raise HTTPException(status_code=413, detail="Input too large") from e
|
| 391 |
+
|
| 392 |
except Exception as e:
|
| 393 |
+
logger.error(
|
| 394 |
+
"Error processing chat request: %s: %s",
|
| 395 |
+
type(e).__name__,
|
| 396 |
+
str(e),
|
| 397 |
+
exc_info=True,
|
| 398 |
+
)
|
| 399 |
raise HTTPException(status_code=500, detail="Internal server error") from e
|
| 400 |
|
| 401 |
|
| 402 |
+
@api_router.get("/analytics/{session_id}")
|
| 403 |
async def get_conversation_history(session_id: str):
|
| 404 |
logger.debug("Analytics requested for session: %s", session_id)
|
| 405 |
|
|
|
|
| 423 |
raise HTTPException(status_code=500, detail="Internal server error") from e
|
| 424 |
|
| 425 |
|
| 426 |
+
@api_router.get("/analytics/{session_id}/stats")
|
| 427 |
async def get_user_stats(session_id: str):
|
| 428 |
logger.debug("User stats requested for session: %s", session_id)
|
| 429 |
|
|
|
|
| 443 |
raise HTTPException(status_code=500, detail="Internal server error") from e
|
| 444 |
|
| 445 |
|
| 446 |
+
app.include_router(api_router)
|
| 447 |
+
|
| 448 |
# Serve frontend static files (HF Spaces has no separate nginx)
|
| 449 |
# Must be mounted LAST so API routes above take precedence
|
| 450 |
_frontend_dir = Path(__file__).parent.parent / "frontend"
|
app/middleware/tenant_auth.py
CHANGED
|
@@ -5,8 +5,7 @@ from sqlalchemy import select
|
|
| 5 |
from starlette.middleware.base import BaseHTTPMiddleware
|
| 6 |
from starlette.responses import JSONResponse
|
| 7 |
|
| 8 |
-
from app.models.database import SessionLocal
|
| 9 |
-
from app.models.smart_models import Tenant
|
| 10 |
|
| 11 |
|
| 12 |
class TenantAuthMiddleware(BaseHTTPMiddleware):
|
|
@@ -23,6 +22,13 @@ class TenantAuthMiddleware(BaseHTTPMiddleware):
|
|
| 23 |
request.method == "OPTIONS"
|
| 24 |
or path in excluded_paths
|
| 25 |
or "." in path.split("/")[-1] # static files (.js, .css, .html, etc.)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
):
|
| 27 |
return await call_next(request)
|
| 28 |
|
|
@@ -46,6 +52,11 @@ class TenantAuthMiddleware(BaseHTTPMiddleware):
|
|
| 46 |
status_code=403, content={"detail": "Tenant is inactive"}
|
| 47 |
)
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
# this makes `tenant_id` available to use. If we need to use other properties like `api_key` we need to explicitly set it this way.
|
| 50 |
request.state.tenant_id = tenant.id
|
| 51 |
|
|
|
|
| 5 |
from starlette.middleware.base import BaseHTTPMiddleware
|
| 6 |
from starlette.responses import JSONResponse
|
| 7 |
|
| 8 |
+
from app.models.database import SessionLocal, Tenant
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
class TenantAuthMiddleware(BaseHTTPMiddleware):
|
|
|
|
| 22 |
request.method == "OPTIONS"
|
| 23 |
or path in excluded_paths
|
| 24 |
or "." in path.split("/")[-1] # static files (.js, .css, .html, etc.)
|
| 25 |
+
or "/admin" in path # admin auth and other admin endpoints use JWT
|
| 26 |
+
or "/billing" in path # billing endpoints use JWT
|
| 27 |
+
or "/kb/entries" in path # admin KB management → JWT auth
|
| 28 |
+
or "/kb/bulk-upload" in path # admin KB bulk upload
|
| 29 |
+
or "/kb/sync" in path # admin KB sync
|
| 30 |
+
or "/system-prompt" in path # admin system-prompt management → JWT auth
|
| 31 |
+
or "/conversations" in path # admin conversations → JWT auth
|
| 32 |
):
|
| 33 |
return await call_next(request)
|
| 34 |
|
|
|
|
| 52 |
status_code=403, content={"detail": "Tenant is inactive"}
|
| 53 |
)
|
| 54 |
|
| 55 |
+
if tenant.is_blocked: # type: ignore[truthy-bool]
|
| 56 |
+
return JSONResponse(
|
| 57 |
+
status_code=403, content={"detail": "Tenant is blocked"}
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
# this makes `tenant_id` available to use. If we need to use other properties like `api_key` we need to explicitly set it this way.
|
| 61 |
request.state.tenant_id = tenant.id
|
| 62 |
|
app/models/conversation_manager.py
CHANGED
|
@@ -1,20 +1,51 @@
|
|
| 1 |
-
import json
|
| 2 |
import re
|
| 3 |
from datetime import datetime, timezone, timedelta
|
| 4 |
-
from typing import
|
| 5 |
-
|
|
|
|
| 6 |
from sqlalchemy.exc import SQLAlchemyError, OperationalError
|
| 7 |
-
from groq import Groq
|
| 8 |
|
| 9 |
-
|
| 10 |
-
from app.utils.
|
| 11 |
-
from app.models.database import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
from app.models.information_extractor import InformationExtractor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
class ConversationManager:
|
| 16 |
-
def __init__(self, config=None):
|
| 17 |
self.config = config
|
|
|
|
| 18 |
self.logger = get_logger(__name__)
|
| 19 |
self.info_extractor = InformationExtractor()
|
| 20 |
|
|
@@ -25,13 +56,13 @@ class ConversationManager:
|
|
| 25 |
max_history,
|
| 26 |
)
|
| 27 |
|
| 28 |
-
def get_or_create_user(self, session_id: str) -> User:
|
| 29 |
"""Get existing user or create new one"""
|
| 30 |
with SessionLocal() as db_session:
|
| 31 |
-
user = db_session.
|
| 32 |
|
| 33 |
if not user:
|
| 34 |
-
user = User(session_id=session_id)
|
| 35 |
db_session.add(user)
|
| 36 |
db_session.commit()
|
| 37 |
db_session.refresh(user)
|
|
@@ -41,6 +72,11 @@ class ConversationManager:
|
|
| 41 |
user.last_seen = datetime.now(timezone.utc)
|
| 42 |
db_session.commit()
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
return user
|
| 45 |
|
| 46 |
def get_active_conversation(self, user: User) -> Optional[Conversation]:
|
|
@@ -49,15 +85,14 @@ class ConversationManager:
|
|
| 49 |
# Refresh user object in this session
|
| 50 |
user = db_session.merge(user)
|
| 51 |
|
| 52 |
-
conversation = (
|
| 53 |
-
|
| 54 |
-
.
|
| 55 |
Conversation.user_id == user.id,
|
| 56 |
Conversation.is_active # pylint: disable=singleton-comparison
|
| 57 |
== True,
|
| 58 |
) # cannot write `Conversation.is_active is True` here, because it's not a Python's `True``, but a SQLAlchemy's `InstrumentedAttribute`` (a special obj that represents the column). Using `is` will always turn False because they are not the same object in Memory
|
| 59 |
.order_by(Conversation.last_message_at.desc())
|
| 60 |
-
.first()
|
| 61 |
)
|
| 62 |
|
| 63 |
# If conversation is older than 30 minutes, consider it inactive
|
|
@@ -71,20 +106,20 @@ class ConversationManager:
|
|
| 71 |
last_message_utc = conversation.last_message_at
|
| 72 |
time_diff = datetime.now(timezone.utc) - last_message_utc
|
| 73 |
|
| 74 |
-
if time_diff > timedelta(minutes=
|
| 75 |
conversation.is_active = False
|
| 76 |
db_session.commit()
|
| 77 |
conversation = None
|
| 78 |
|
| 79 |
return conversation
|
| 80 |
|
| 81 |
-
def create_conversation(self, user: User) -> Conversation:
|
| 82 |
"""Create a new conversation for the user"""
|
| 83 |
with SessionLocal() as db_session:
|
| 84 |
# Refresh user object in this session
|
| 85 |
user = db_session.merge(user)
|
| 86 |
|
| 87 |
-
conversation = Conversation(user_id=user.id)
|
| 88 |
db_session.add(conversation)
|
| 89 |
db_session.commit()
|
| 90 |
db_session.refresh(conversation)
|
|
@@ -93,20 +128,19 @@ class ConversationManager:
|
|
| 93 |
return conversation
|
| 94 |
|
| 95 |
def get_conversation_context(
|
| 96 |
-
self, conversation: Conversation, limit: int =
|
| 97 |
-
) ->
|
| 98 |
"""Get recent messages from conversation for context"""
|
| 99 |
with SessionLocal() as db_session:
|
| 100 |
# Refresh conversation object in this section
|
| 101 |
conversation = db_session.merge(conversation)
|
| 102 |
|
| 103 |
-
messages = (
|
| 104 |
-
|
| 105 |
-
.
|
| 106 |
.order_by(Message.timestamp.desc())
|
| 107 |
.limit(limit)
|
| 108 |
-
|
| 109 |
-
)
|
| 110 |
|
| 111 |
context = []
|
| 112 |
for msg in reversed(messages): # Reverse to get chronological order
|
|
@@ -115,7 +149,9 @@ class ConversationManager:
|
|
| 115 |
"user_input": msg.user_input,
|
| 116 |
"bot_response": msg.bot_response,
|
| 117 |
"intent": msg.intent,
|
| 118 |
-
"timestamp":
|
|
|
|
|
|
|
| 119 |
}
|
| 120 |
)
|
| 121 |
return context
|
|
@@ -137,7 +173,9 @@ class ConversationManager:
|
|
| 137 |
)
|
| 138 |
|
| 139 |
thinking_block = thinking_block_match.group(1)
|
| 140 |
-
bot_response =
|
|
|
|
|
|
|
| 141 |
else:
|
| 142 |
bot_response = content
|
| 143 |
|
|
@@ -148,32 +186,70 @@ class ConversationManager:
|
|
| 148 |
intent: str,
|
| 149 |
language: str,
|
| 150 |
user_input: str,
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
| 154 |
"""Generate response with context awareness"""
|
| 155 |
|
| 156 |
history_messages = [] # Initialize history messages for multi-turn context
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
if session_id:
|
|
|
|
| 159 |
try:
|
| 160 |
-
user = self.get_or_create_user(
|
|
|
|
|
|
|
| 161 |
conversation = self.get_active_conversation(user)
|
| 162 |
|
| 163 |
if conversation:
|
| 164 |
-
context = self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
# Prepare history messages for LLM context
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
# Update user's preferred language
|
| 179 |
if user and user.preferred_language != language:
|
|
@@ -194,21 +270,19 @@ class ConversationManager:
|
|
| 194 |
)
|
| 195 |
raise
|
| 196 |
|
| 197 |
-
|
| 198 |
-
client = Groq(api_key=self.config.llm_api_key)
|
| 199 |
|
| 200 |
-
|
| 201 |
-
messages=[{"role": "system", "content": self.config.system_prompt}]
|
| 202 |
-
+ history_messages
|
| 203 |
-
+ [{"role": "user", "content": user_input}],
|
| 204 |
-
model="qwen/qwen3-32b",
|
| 205 |
-
)
|
| 206 |
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
| 209 |
)
|
| 210 |
|
| 211 |
-
|
|
|
|
|
|
|
| 212 |
|
| 213 |
def save_conversation(
|
| 214 |
self,
|
|
@@ -216,20 +290,23 @@ class ConversationManager:
|
|
| 216 |
user_input: str,
|
| 217 |
bot_response: str,
|
| 218 |
intent: str,
|
| 219 |
-
|
| 220 |
-
|
|
|
|
| 221 |
language: str = "en",
|
| 222 |
-
response_time_ms: int = None,
|
| 223 |
-
|
|
|
|
|
|
|
| 224 |
"""Save conversation to database"""
|
| 225 |
try:
|
| 226 |
# Get or create user
|
| 227 |
-
user = self.get_or_create_user(session_id)
|
| 228 |
|
| 229 |
# Get or create active conversation
|
| 230 |
conversation = self.get_active_conversation(user)
|
| 231 |
if not conversation:
|
| 232 |
-
conversation = self.create_conversation(user)
|
| 233 |
|
| 234 |
# Create message record
|
| 235 |
with SessionLocal() as db_session:
|
|
@@ -241,26 +318,39 @@ class ConversationManager:
|
|
| 241 |
conversation_id=conversation.id,
|
| 242 |
user_input=user_input,
|
| 243 |
bot_response=bot_response,
|
|
|
|
| 244 |
detected_language=language,
|
| 245 |
intent=intent,
|
| 246 |
confidence=confidence,
|
| 247 |
-
entities=
|
| 248 |
response_time_ms=response_time_ms,
|
| 249 |
)
|
| 250 |
|
| 251 |
db_session.add(message)
|
| 252 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
# Update conversation stats
|
| 254 |
conversation.last_message_at = datetime.now(timezone.utc)
|
| 255 |
-
conversation.
|
|
|
|
| 256 |
|
| 257 |
# Update user stats
|
| 258 |
-
user.total_messages
|
| 259 |
user.last_seen = datetime.now(timezone.utc)
|
| 260 |
|
| 261 |
db_session.commit()
|
| 262 |
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
# Test the extracted info (temp)
|
| 266 |
extracted_info = self.info_extractor.extract_user_information(
|
|
@@ -268,78 +358,180 @@ class ConversationManager:
|
|
| 268 |
)
|
| 269 |
self.logger.info("Extracted info: %s", extracted_info)
|
| 270 |
|
| 271 |
-
|
|
|
|
|
|
|
| 272 |
self.logger.error("Failed to save conversation: %s", str(e), exc_info=True)
|
| 273 |
|
| 274 |
-
def get_conversation_history(self, session_id: str) ->
|
| 275 |
"""Get conversation history for a session (for analytics endpoint)"""
|
| 276 |
try:
|
| 277 |
with SessionLocal() as db_session:
|
| 278 |
-
user = (
|
| 279 |
-
|
| 280 |
)
|
| 281 |
if not user:
|
| 282 |
return []
|
| 283 |
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
.all()
|
| 291 |
-
)
|
| 292 |
|
| 293 |
history = []
|
| 294 |
for msg in reversed(messages): # Reverse for chronological order
|
| 295 |
history.append(
|
| 296 |
{
|
| 297 |
-
"timestamp":
|
|
|
|
|
|
|
| 298 |
"user_input": msg.user_input,
|
| 299 |
"bot_response": msg.bot_response,
|
| 300 |
"intent": msg.intent,
|
| 301 |
"confidence": msg.confidence,
|
| 302 |
"language": msg.detected_language,
|
| 303 |
-
"entities":
|
| 304 |
}
|
| 305 |
)
|
| 306 |
|
| 307 |
return history
|
| 308 |
|
| 309 |
-
except Exception as e:
|
| 310 |
self.logger.error(
|
| 311 |
"Failed to get conversation history: %s", str(e), exc_info=True
|
| 312 |
)
|
| 313 |
return []
|
| 314 |
|
| 315 |
-
def get_user_stats(self, session_id: str) ->
|
| 316 |
"""Get user statistics"""
|
| 317 |
try:
|
| 318 |
with SessionLocal() as db_session:
|
| 319 |
-
user = (
|
| 320 |
-
|
| 321 |
)
|
| 322 |
if not user:
|
| 323 |
return {}
|
| 324 |
|
| 325 |
# Get conversation count
|
| 326 |
-
conversation_count = (
|
| 327 |
-
|
| 328 |
func.count(Conversation.id) # pylint: disable=not-callable
|
| 329 |
-
)
|
| 330 |
-
.filter(Conversation.user_id == user.id)
|
| 331 |
-
.scalar()
|
| 332 |
)
|
| 333 |
|
| 334 |
return {
|
| 335 |
"session_id": user.session_id,
|
| 336 |
"preferred_language": user.preferred_language,
|
| 337 |
-
"first_seen":
|
| 338 |
-
|
|
|
|
|
|
|
| 339 |
"total_messages": user.total_messages,
|
| 340 |
"total_conversations": conversation_count,
|
| 341 |
}
|
| 342 |
|
| 343 |
-
except Exception as e:
|
| 344 |
self.logger.error("Failed to get user stats: %s", str(e), exc_info=True)
|
| 345 |
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import re
|
| 2 |
from datetime import datetime, timezone, timedelta
|
| 3 |
+
from typing import Optional
|
| 4 |
+
import uuid
|
| 5 |
+
from sqlalchemy import func, select
|
| 6 |
from sqlalchemy.exc import SQLAlchemyError, OperationalError
|
|
|
|
| 7 |
|
| 8 |
+
from app.utils.config import Config, get_logger
|
| 9 |
+
from app.utils.token_counter import count_tokens
|
| 10 |
+
from app.models.database import (
|
| 11 |
+
SummarizationJob,
|
| 12 |
+
User,
|
| 13 |
+
Conversation,
|
| 14 |
+
Message,
|
| 15 |
+
SessionLocal,
|
| 16 |
+
)
|
| 17 |
from app.models.information_extractor import InformationExtractor
|
| 18 |
+
from app.services.llm_service import LLMResult, LLMService
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
CONVERSATION_TIMEOUT_MINUTES = 30
|
| 22 |
+
CONTEXT_MESSAGE_LIMIT = 5
|
| 23 |
+
|
| 24 |
+
# Hierarchical Context Memory
|
| 25 |
+
CHARS_PER_TOKEN = 4
|
| 26 |
+
DB_FETCH_LIMIT = 100
|
| 27 |
+
SUMMARIZATION_SYSTEM_PROMPT = (
|
| 28 |
+
"You are a conversation summarizer. You will be given a conversation history "
|
| 29 |
+
"between a user and an assistant. Summarize the key points of the conversation "
|
| 30 |
+
"in concise prose, covering both what the user asked and what the assistant "
|
| 31 |
+
"responded. Focus on topics, decisions, and information exchanged. "
|
| 32 |
+
"Do not include greetings or filler. Write in third person. "
|
| 33 |
+
"Keep the summary under 200 words."
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
RAG_SYSTEM_PROMPT = (
|
| 37 |
+
"Use the knowledge base context as the primary source of truth for any "
|
| 38 |
+
"business-specific question. If the answer is present in the knowledge base "
|
| 39 |
+
"context, answer from it directly and do not fall back to generic assistant "
|
| 40 |
+
"memory. If the context does not contain the answer, say that you could not "
|
| 41 |
+
"find it in the knowledge base and avoid guessing."
|
| 42 |
+
)
|
| 43 |
|
| 44 |
|
| 45 |
class ConversationManager:
|
| 46 |
+
def __init__(self, config: Config, llm_service: LLMService | None = None):
|
| 47 |
self.config = config
|
| 48 |
+
self.llm_service = llm_service or LLMService()
|
| 49 |
self.logger = get_logger(__name__)
|
| 50 |
self.info_extractor = InformationExtractor()
|
| 51 |
|
|
|
|
| 56 |
max_history,
|
| 57 |
)
|
| 58 |
|
| 59 |
+
def get_or_create_user(self, session_id: str, tenant_id: uuid.UUID) -> User:
|
| 60 |
"""Get existing user or create new one"""
|
| 61 |
with SessionLocal() as db_session:
|
| 62 |
+
user = db_session.scalar(select(User).where(User.session_id == session_id))
|
| 63 |
|
| 64 |
if not user:
|
| 65 |
+
user = User(session_id=session_id, tenant_id=tenant_id)
|
| 66 |
db_session.add(user)
|
| 67 |
db_session.commit()
|
| 68 |
db_session.refresh(user)
|
|
|
|
| 72 |
user.last_seen = datetime.now(timezone.utc)
|
| 73 |
db_session.commit()
|
| 74 |
|
| 75 |
+
# Reload all columns into memory before the session closes.
|
| 76 |
+
# After commit(), SQLAlchemy marks all attributes as expired.
|
| 77 |
+
# Without this, accessing user.preferred_language outside the
|
| 78 |
+
# `with` block triggers a lazy-load on a detached object → DetachedInstanceError.
|
| 79 |
+
db_session.refresh(user)
|
| 80 |
return user
|
| 81 |
|
| 82 |
def get_active_conversation(self, user: User) -> Optional[Conversation]:
|
|
|
|
| 85 |
# Refresh user object in this session
|
| 86 |
user = db_session.merge(user)
|
| 87 |
|
| 88 |
+
conversation = db_session.scalar(
|
| 89 |
+
select(Conversation)
|
| 90 |
+
.where(
|
| 91 |
Conversation.user_id == user.id,
|
| 92 |
Conversation.is_active # pylint: disable=singleton-comparison
|
| 93 |
== True,
|
| 94 |
) # cannot write `Conversation.is_active is True` here, because it's not a Python's `True``, but a SQLAlchemy's `InstrumentedAttribute`` (a special obj that represents the column). Using `is` will always turn False because they are not the same object in Memory
|
| 95 |
.order_by(Conversation.last_message_at.desc())
|
|
|
|
| 96 |
)
|
| 97 |
|
| 98 |
# If conversation is older than 30 minutes, consider it inactive
|
|
|
|
| 106 |
last_message_utc = conversation.last_message_at
|
| 107 |
time_diff = datetime.now(timezone.utc) - last_message_utc
|
| 108 |
|
| 109 |
+
if time_diff > timedelta(minutes=CONVERSATION_TIMEOUT_MINUTES):
|
| 110 |
conversation.is_active = False
|
| 111 |
db_session.commit()
|
| 112 |
conversation = None
|
| 113 |
|
| 114 |
return conversation
|
| 115 |
|
| 116 |
+
def create_conversation(self, user: User, tenant_id: uuid.UUID) -> Conversation:
|
| 117 |
"""Create a new conversation for the user"""
|
| 118 |
with SessionLocal() as db_session:
|
| 119 |
# Refresh user object in this session
|
| 120 |
user = db_session.merge(user)
|
| 121 |
|
| 122 |
+
conversation = Conversation(user_id=user.id, tenant_id=tenant_id)
|
| 123 |
db_session.add(conversation)
|
| 124 |
db_session.commit()
|
| 125 |
db_session.refresh(conversation)
|
|
|
|
| 128 |
return conversation
|
| 129 |
|
| 130 |
def get_conversation_context(
|
| 131 |
+
self, conversation: Conversation, limit: int = CONTEXT_MESSAGE_LIMIT
|
| 132 |
+
) -> list[dict]:
|
| 133 |
"""Get recent messages from conversation for context"""
|
| 134 |
with SessionLocal() as db_session:
|
| 135 |
# Refresh conversation object in this section
|
| 136 |
conversation = db_session.merge(conversation)
|
| 137 |
|
| 138 |
+
messages = db_session.scalars(
|
| 139 |
+
select(Message)
|
| 140 |
+
.where(Message.conversation_id == conversation.id)
|
| 141 |
.order_by(Message.timestamp.desc())
|
| 142 |
.limit(limit)
|
| 143 |
+
).all()
|
|
|
|
| 144 |
|
| 145 |
context = []
|
| 146 |
for msg in reversed(messages): # Reverse to get chronological order
|
|
|
|
| 149 |
"user_input": msg.user_input,
|
| 150 |
"bot_response": msg.bot_response,
|
| 151 |
"intent": msg.intent,
|
| 152 |
+
"timestamp": (
|
| 153 |
+
msg.timestamp.isoformat() if msg.timestamp else None
|
| 154 |
+
),
|
| 155 |
}
|
| 156 |
)
|
| 157 |
return context
|
|
|
|
| 173 |
)
|
| 174 |
|
| 175 |
thinking_block = thinking_block_match.group(1)
|
| 176 |
+
bot_response = (
|
| 177 |
+
bot_response_match.group(1) if bot_response_match else content
|
| 178 |
+
)
|
| 179 |
else:
|
| 180 |
bot_response = content
|
| 181 |
|
|
|
|
| 186 |
intent: str,
|
| 187 |
language: str,
|
| 188 |
user_input: str,
|
| 189 |
+
tenant_id: uuid.UUID,
|
| 190 |
+
retrieved_docs: Optional[list[str]] = None,
|
| 191 |
+
session_id: Optional[str] = None,
|
| 192 |
+
entities: Optional[dict] = None,
|
| 193 |
+
system_prompt: Optional[str] = None,
|
| 194 |
+
) -> tuple[Optional[str], Optional[str], bool, LLMResult]:
|
| 195 |
"""Generate response with context awareness"""
|
| 196 |
|
| 197 |
history_messages = [] # Initialize history messages for multi-turn context
|
| 198 |
+
summary_message = ""
|
| 199 |
+
should_summarize = False
|
| 200 |
+
|
| 201 |
+
base_prompt = system_prompt or self.config.system_prompt
|
| 202 |
+
system_content = base_prompt
|
| 203 |
+
if retrieved_docs:
|
| 204 |
+
knowledge_base_entries = "\n\n".join(retrieved_docs)
|
| 205 |
+
system_content = (
|
| 206 |
+
f"{base_prompt}\n\n"
|
| 207 |
+
f"{RAG_SYSTEM_PROMPT}\n\n"
|
| 208 |
+
f"Relevant knowledge base context:\n{knowledge_base_entries}"
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
messages = [
|
| 212 |
+
{"role": "system", "content": system_content},
|
| 213 |
+
{"role": "user", "content": user_input},
|
| 214 |
+
]
|
| 215 |
|
| 216 |
if session_id:
|
| 217 |
+
user = None
|
| 218 |
try:
|
| 219 |
+
user = self.get_or_create_user(
|
| 220 |
+
session_id=session_id, tenant_id=tenant_id
|
| 221 |
+
)
|
| 222 |
conversation = self.get_active_conversation(user)
|
| 223 |
|
| 224 |
if conversation:
|
| 225 |
+
context = self.build_hierarchical_context(
|
| 226 |
+
conversation=conversation, system_prompt=system_content
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
if context["summary"] is not None:
|
| 230 |
+
summary_message = context["summary"]
|
| 231 |
+
should_summarize = True
|
| 232 |
|
| 233 |
# Prepare history messages for LLM context
|
| 234 |
+
history_messages.extend(context["verbatim"])
|
| 235 |
+
|
| 236 |
+
# Reassign messages now — before the language update — so a
|
| 237 |
+
# failure there can never prevent history from being passed.
|
| 238 |
+
messages = (
|
| 239 |
+
[{"role": "system", "content": system_content}]
|
| 240 |
+
+ (
|
| 241 |
+
[
|
| 242 |
+
{
|
| 243 |
+
"role": "system",
|
| 244 |
+
"content": f"Summary of earlier conversation:\n{summary_message}",
|
| 245 |
+
}
|
| 246 |
+
]
|
| 247 |
+
if summary_message
|
| 248 |
+
else []
|
| 249 |
+
)
|
| 250 |
+
+ history_messages
|
| 251 |
+
+ [{"role": "user", "content": user_input}]
|
| 252 |
+
)
|
| 253 |
|
| 254 |
# Update user's preferred language
|
| 255 |
if user and user.preferred_language != language:
|
|
|
|
| 270 |
)
|
| 271 |
raise
|
| 272 |
|
| 273 |
+
count_tokens(messages)
|
|
|
|
| 274 |
|
| 275 |
+
self.logger.info("History messages count: %d", len(history_messages))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
|
| 277 |
+
llm_result = self.llm_service.complete_with_usage(
|
| 278 |
+
"chat-model",
|
| 279 |
+
messages,
|
| 280 |
+
max_tokens=self.config.chat_max_output_tokens,
|
| 281 |
)
|
| 282 |
|
| 283 |
+
thinking_block, response = self.parse_thinking_block(llm_result.content)
|
| 284 |
+
|
| 285 |
+
return thinking_block, response, should_summarize, llm_result
|
| 286 |
|
| 287 |
def save_conversation(
|
| 288 |
self,
|
|
|
|
| 290 |
user_input: str,
|
| 291 |
bot_response: str,
|
| 292 |
intent: str,
|
| 293 |
+
tenant_id: uuid.UUID,
|
| 294 |
+
confidence: Optional[float] = None,
|
| 295 |
+
entities: Optional[dict] = None,
|
| 296 |
language: str = "en",
|
| 297 |
+
response_time_ms: Optional[int] = None,
|
| 298 |
+
retrieved_doc_ids: Optional[list[str]] = None,
|
| 299 |
+
should_summarize: bool = False,
|
| 300 |
+
) -> Optional[uuid.UUID]:
|
| 301 |
"""Save conversation to database"""
|
| 302 |
try:
|
| 303 |
# Get or create user
|
| 304 |
+
user = self.get_or_create_user(session_id=session_id, tenant_id=tenant_id)
|
| 305 |
|
| 306 |
# Get or create active conversation
|
| 307 |
conversation = self.get_active_conversation(user)
|
| 308 |
if not conversation:
|
| 309 |
+
conversation = self.create_conversation(user=user, tenant_id=tenant_id)
|
| 310 |
|
| 311 |
# Create message record
|
| 312 |
with SessionLocal() as db_session:
|
|
|
|
| 318 |
conversation_id=conversation.id,
|
| 319 |
user_input=user_input,
|
| 320 |
bot_response=bot_response,
|
| 321 |
+
retrieved_doc_ids=retrieved_doc_ids,
|
| 322 |
detected_language=language,
|
| 323 |
intent=intent,
|
| 324 |
confidence=confidence,
|
| 325 |
+
entities=entities if entities else None,
|
| 326 |
response_time_ms=response_time_ms,
|
| 327 |
)
|
| 328 |
|
| 329 |
db_session.add(message)
|
| 330 |
|
| 331 |
+
# Check if SummarizationJob should be executed
|
| 332 |
+
if should_summarize:
|
| 333 |
+
db_session.add(SummarizationJob(conversation_id=conversation.id))
|
| 334 |
+
|
| 335 |
# Update conversation stats
|
| 336 |
conversation.last_message_at = datetime.now(timezone.utc)
|
| 337 |
+
conversation.last_message_preview = user_input[:255]
|
| 338 |
+
conversation.message_count = (conversation.message_count or 0) + 1
|
| 339 |
|
| 340 |
# Update user stats
|
| 341 |
+
user.total_messages = (user.total_messages or 0) + 1
|
| 342 |
user.last_seen = datetime.now(timezone.utc)
|
| 343 |
|
| 344 |
db_session.commit()
|
| 345 |
|
| 346 |
+
# Capture message.id
|
| 347 |
+
message_id: Optional[uuid.UUID] = message.id
|
| 348 |
+
|
| 349 |
+
self.logger.debug(
|
| 350 |
+
"Conversation saved for session: %s, under tenant: %s",
|
| 351 |
+
session_id,
|
| 352 |
+
str(tenant_id),
|
| 353 |
+
)
|
| 354 |
|
| 355 |
# Test the extracted info (temp)
|
| 356 |
extracted_info = self.info_extractor.extract_user_information(
|
|
|
|
| 358 |
)
|
| 359 |
self.logger.info("Extracted info: %s", extracted_info)
|
| 360 |
|
| 361 |
+
return message_id
|
| 362 |
+
|
| 363 |
+
except Exception as e: # pylint: disable=broad-exception-caught
|
| 364 |
self.logger.error("Failed to save conversation: %s", str(e), exc_info=True)
|
| 365 |
|
| 366 |
+
def get_conversation_history(self, session_id: str) -> list[dict]:
|
| 367 |
"""Get conversation history for a session (for analytics endpoint)"""
|
| 368 |
try:
|
| 369 |
with SessionLocal() as db_session:
|
| 370 |
+
user = db_session.scalar(
|
| 371 |
+
select(User).where(User.session_id == session_id)
|
| 372 |
)
|
| 373 |
if not user:
|
| 374 |
return []
|
| 375 |
|
| 376 |
+
messages = db_session.scalars(
|
| 377 |
+
select(Message)
|
| 378 |
+
.join(Conversation)
|
| 379 |
+
.where(Conversation.user_id == user.id)
|
| 380 |
+
.order_by(Message.timestamp.desc())
|
| 381 |
+
.limit(self.config.nlp["max_history"] if self.config else 50)
|
| 382 |
+
).all()
|
|
|
|
| 383 |
|
| 384 |
history = []
|
| 385 |
for msg in reversed(messages): # Reverse for chronological order
|
| 386 |
history.append(
|
| 387 |
{
|
| 388 |
+
"timestamp": (
|
| 389 |
+
msg.timestamp.isoformat() if msg.timestamp else None
|
| 390 |
+
),
|
| 391 |
"user_input": msg.user_input,
|
| 392 |
"bot_response": msg.bot_response,
|
| 393 |
"intent": msg.intent,
|
| 394 |
"confidence": msg.confidence,
|
| 395 |
"language": msg.detected_language,
|
| 396 |
+
"entities": msg.entities if msg.entities else {},
|
| 397 |
}
|
| 398 |
)
|
| 399 |
|
| 400 |
return history
|
| 401 |
|
| 402 |
+
except Exception as e: # pylint: disable=broad-exception-caught
|
| 403 |
self.logger.error(
|
| 404 |
"Failed to get conversation history: %s", str(e), exc_info=True
|
| 405 |
)
|
| 406 |
return []
|
| 407 |
|
| 408 |
+
def get_user_stats(self, session_id: str) -> dict:
|
| 409 |
"""Get user statistics"""
|
| 410 |
try:
|
| 411 |
with SessionLocal() as db_session:
|
| 412 |
+
user = db_session.scalar(
|
| 413 |
+
select(User).where(User.session_id == session_id)
|
| 414 |
)
|
| 415 |
if not user:
|
| 416 |
return {}
|
| 417 |
|
| 418 |
# Get conversation count
|
| 419 |
+
conversation_count = db_session.scalar(
|
| 420 |
+
select(
|
| 421 |
func.count(Conversation.id) # pylint: disable=not-callable
|
| 422 |
+
).where(Conversation.user_id == user.id)
|
|
|
|
|
|
|
| 423 |
)
|
| 424 |
|
| 425 |
return {
|
| 426 |
"session_id": user.session_id,
|
| 427 |
"preferred_language": user.preferred_language,
|
| 428 |
+
"first_seen": (
|
| 429 |
+
user.first_seen.isoformat() if user.first_seen else None
|
| 430 |
+
),
|
| 431 |
+
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
|
| 432 |
"total_messages": user.total_messages,
|
| 433 |
"total_conversations": conversation_count,
|
| 434 |
}
|
| 435 |
|
| 436 |
+
except Exception as e: # pylint: disable=broad-exception-caught
|
| 437 |
self.logger.error("Failed to get user stats: %s", str(e), exc_info=True)
|
| 438 |
return {}
|
| 439 |
+
|
| 440 |
+
# Methods for Hierarchical Context Memory
|
| 441 |
+
def _split_verbatim_and_displaced(
|
| 442 |
+
self, messages: list[dict], budget: int
|
| 443 |
+
) -> tuple[list[dict], list[dict]]:
|
| 444 |
+
|
| 445 |
+
token_count = 0
|
| 446 |
+
cutoff = 0
|
| 447 |
+
|
| 448 |
+
for msg in reversed(messages):
|
| 449 |
+
msg_tokens = (
|
| 450 |
+
len(msg["user_input"]) + len(msg["bot_response"])
|
| 451 |
+
) // CHARS_PER_TOKEN
|
| 452 |
+
|
| 453 |
+
if token_count + msg_tokens > budget:
|
| 454 |
+
break
|
| 455 |
+
|
| 456 |
+
token_count += msg_tokens
|
| 457 |
+
cutoff += 1
|
| 458 |
+
|
| 459 |
+
verbatim = messages[len(messages) - cutoff :]
|
| 460 |
+
displaced = messages[: len(messages) - cutoff]
|
| 461 |
+
|
| 462 |
+
return verbatim, displaced
|
| 463 |
+
|
| 464 |
+
def build_hierarchical_context(
|
| 465 |
+
self,
|
| 466 |
+
conversation: Conversation,
|
| 467 |
+
system_prompt: str,
|
| 468 |
+
) -> dict:
|
| 469 |
+
|
| 470 |
+
# Get a list of formatted messages
|
| 471 |
+
with SessionLocal() as db_session:
|
| 472 |
+
db_session.merge(conversation)
|
| 473 |
+
|
| 474 |
+
messages = db_session.scalars(
|
| 475 |
+
select(Message)
|
| 476 |
+
.where(Message.conversation_id == conversation.id)
|
| 477 |
+
.limit(DB_FETCH_LIMIT)
|
| 478 |
+
.order_by(Message.timestamp.asc())
|
| 479 |
+
).all()
|
| 480 |
+
|
| 481 |
+
formatted_messages = [
|
| 482 |
+
{"user_input": msg.user_input, "bot_response": msg.bot_response}
|
| 483 |
+
for msg in messages
|
| 484 |
+
]
|
| 485 |
+
|
| 486 |
+
# Calculate the budget tokens
|
| 487 |
+
max_input_tokens = self.config.nlp["max_input_tokens"]
|
| 488 |
+
context_reserve_tokens = self.config.nlp["context_reserve_tokens"]
|
| 489 |
+
system_tokens = count_tokens(
|
| 490 |
+
[{"role": "system", "content": system_prompt or self.config.system_prompt}]
|
| 491 |
+
)
|
| 492 |
+
summary_tokens = (
|
| 493 |
+
len(conversation.context_summary) // CHARS_PER_TOKEN
|
| 494 |
+
if conversation.context_summary
|
| 495 |
+
else 0
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
budget = (
|
| 499 |
+
max_input_tokens - system_tokens - summary_tokens - context_reserve_tokens
|
| 500 |
+
)
|
| 501 |
+
|
| 502 |
+
verbatim, displaced = self._split_verbatim_and_displaced(
|
| 503 |
+
messages=formatted_messages, budget=budget
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
summary = self._summarize_messages(displaced) if displaced else None
|
| 507 |
+
verbatim_formatted = [
|
| 508 |
+
item
|
| 509 |
+
for msg in verbatim
|
| 510 |
+
for item in (
|
| 511 |
+
{"role": "user", "content": msg["user_input"]},
|
| 512 |
+
{"role": "assistant", "content": msg["bot_response"]},
|
| 513 |
+
)
|
| 514 |
+
]
|
| 515 |
+
|
| 516 |
+
return {"verbatim": verbatim_formatted, "summary": summary}
|
| 517 |
+
|
| 518 |
+
def _summarize_messages(self, messages: list[dict]) -> Optional[str]:
|
| 519 |
+
|
| 520 |
+
formatted_messages: list[dict] = []
|
| 521 |
+
for msg in messages:
|
| 522 |
+
formatted_messages.extend(
|
| 523 |
+
[
|
| 524 |
+
{"role": "user", "content": msg["user_input"]},
|
| 525 |
+
{"role": "assistant", "content": msg["bot_response"]},
|
| 526 |
+
]
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
+
formatted_messages.insert(
|
| 530 |
+
0, {"role": "system", "content": SUMMARIZATION_SYSTEM_PROMPT}
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
content = self.llm_service.complete("summarization-model", formatted_messages)
|
| 534 |
+
|
| 535 |
+
_, response = self.parse_thinking_block(content)
|
| 536 |
+
|
| 537 |
+
return response
|
app/models/database.py
CHANGED
|
@@ -1,9 +1,17 @@
|
|
| 1 |
# app/models/database.py
|
| 2 |
-
from datetime import datetime, timezone
|
|
|
|
|
|
|
| 3 |
import uuid
|
| 4 |
from sqlalchemy import (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
create_engine,
|
| 6 |
-
Column,
|
| 7 |
Integer,
|
| 8 |
String,
|
| 9 |
Text,
|
|
@@ -12,19 +20,30 @@ from sqlalchemy import (
|
|
| 12 |
Boolean,
|
| 13 |
ForeignKey,
|
| 14 |
MetaData,
|
|
|
|
| 15 |
)
|
| 16 |
|
| 17 |
# from sqlalchemy.ext.declarative import declarative_base
|
| 18 |
-
from sqlalchemy.orm import
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
# Absolute import from project root
|
| 22 |
from app.utils.config import get_logger, config_manager
|
| 23 |
|
|
|
|
|
|
|
|
|
|
| 24 |
config = config_manager.get_config()
|
| 25 |
|
| 26 |
logger = get_logger(__name__)
|
| 27 |
|
|
|
|
|
|
|
| 28 |
convention = {
|
| 29 |
"ix": "ix_%(column_0_label)s",
|
| 30 |
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
@@ -33,7 +52,21 @@ convention = {
|
|
| 33 |
"pk": "pk_%(table_name)s",
|
| 34 |
}
|
| 35 |
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
engine = create_engine(
|
| 39 |
config.database_url,
|
|
@@ -48,25 +81,33 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
| 48 |
class User(Base):
|
| 49 |
__tablename__ = "users"
|
| 50 |
|
| 51 |
-
id
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
# Relationships
|
| 60 |
-
|
|
|
|
| 61 |
"Conversation", back_populates="user", cascade="all, delete-orphan"
|
| 62 |
)
|
| 63 |
-
preferences = relationship(
|
| 64 |
"UserPreference", back_populates="user", cascade="all, delete-orphan"
|
| 65 |
)
|
| 66 |
-
insights = relationship(
|
| 67 |
"UserInsight", back_populates="user", cascade="all, delete-orphan"
|
| 68 |
)
|
| 69 |
-
tenant = relationship("Tenant", back_populates="users")
|
| 70 |
|
| 71 |
def __repr__(self):
|
| 72 |
return (
|
|
@@ -77,23 +118,37 @@ class User(Base):
|
|
| 77 |
class Conversation(Base):
|
| 78 |
__tablename__ = "conversations"
|
| 79 |
|
| 80 |
-
id
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
tenant_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
# Relationship
|
| 89 |
-
user = relationship("User", back_populates="conversations")
|
| 90 |
-
messages = relationship(
|
| 91 |
"Message", back_populates="conversation", cascade="all, delete-orphan"
|
| 92 |
)
|
| 93 |
-
topics = relationship(
|
| 94 |
"ConversationTopic", back_populates="conversation", cascade="all, delete-orphan"
|
| 95 |
)
|
| 96 |
-
tenant = relationship("Tenant", back_populates="conversations")
|
| 97 |
|
| 98 |
def __repr__(self):
|
| 99 |
return f"<Conversation(id={self.id}, user_id={self.user_id}, messages={self.message_count})>"
|
|
@@ -102,36 +157,258 @@ class Conversation(Base):
|
|
| 102 |
class Message(Base):
|
| 103 |
__tablename__ = "messages"
|
| 104 |
|
| 105 |
-
id
|
| 106 |
-
|
|
|
|
|
|
|
| 107 |
UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=False
|
| 108 |
)
|
| 109 |
|
| 110 |
# Message content
|
| 111 |
-
user_input =
|
| 112 |
-
bot_response =
|
| 113 |
|
| 114 |
# NLP analysis
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
# Metadata
|
| 121 |
-
timestamp
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
# Relationships
|
| 125 |
-
conversation
|
|
|
|
|
|
|
| 126 |
|
| 127 |
def __repr__(self):
|
| 128 |
return f"<Message(id={self.id}, intent={self.intent}, confidence={self.confidence})>"
|
| 129 |
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
def init_database():
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
|
| 137 |
def health_check():
|
|
@@ -149,6 +426,11 @@ def health_check():
|
|
| 149 |
|
| 150 |
|
| 151 |
def get_db():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
db = SessionLocal()
|
| 153 |
try:
|
| 154 |
yield db
|
|
|
|
| 1 |
# app/models/database.py
|
| 2 |
+
from datetime import datetime, timezone, date
|
| 3 |
+
from decimal import Decimal
|
| 4 |
+
from typing import Optional, TYPE_CHECKING
|
| 5 |
import uuid
|
| 6 |
from sqlalchemy import (
|
| 7 |
+
JSON,
|
| 8 |
+
Index,
|
| 9 |
+
Numeric,
|
| 10 |
+
Date,
|
| 11 |
+
SmallInteger,
|
| 12 |
+
UniqueConstraint,
|
| 13 |
+
CheckConstraint,
|
| 14 |
create_engine,
|
|
|
|
| 15 |
Integer,
|
| 16 |
String,
|
| 17 |
Text,
|
|
|
|
| 20 |
Boolean,
|
| 21 |
ForeignKey,
|
| 22 |
MetaData,
|
| 23 |
+
text,
|
| 24 |
)
|
| 25 |
|
| 26 |
# from sqlalchemy.ext.declarative import declarative_base
|
| 27 |
+
from sqlalchemy.orm import (
|
| 28 |
+
sessionmaker,
|
| 29 |
+
relationship,
|
| 30 |
+
DeclarativeBase,
|
| 31 |
+
Mapped,
|
| 32 |
+
mapped_column,
|
| 33 |
+
)
|
| 34 |
+
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
| 35 |
|
|
|
|
| 36 |
from app.utils.config import get_logger, config_manager
|
| 37 |
|
| 38 |
+
if TYPE_CHECKING:
|
| 39 |
+
from app.models.smart_models import UserInsight, UserPreference, ConversationTopic
|
| 40 |
+
|
| 41 |
config = config_manager.get_config()
|
| 42 |
|
| 43 |
logger = get_logger(__name__)
|
| 44 |
|
| 45 |
+
# Naming conventions for auto-generated constraint names (indexes, unique constraints,
|
| 46 |
+
# foreign keys, etc.) so Alembic can reliably identify and diff them across migrations.
|
| 47 |
convention = {
|
| 48 |
"ix": "ix_%(column_0_label)s",
|
| 49 |
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
|
|
| 52 |
"pk": "pk_%(table_name)s",
|
| 53 |
}
|
| 54 |
|
| 55 |
+
|
| 56 |
+
class Base(DeclarativeBase):
|
| 57 |
+
"""Declarative base class for all ORM models.
|
| 58 |
+
|
| 59 |
+
Defined as a class (subclassing DeclarativeBase) rather than the legacy
|
| 60 |
+
`Base = declarative_base()` pattern because SQLAlchemy 2.0 requires the
|
| 61 |
+
class-based form to support `Mapped[...]` type annotations and
|
| 62 |
+
`mapped_column()`. The old factory function does not support these features.
|
| 63 |
+
|
| 64 |
+
All ORM models inherit from this class to register themselves with the
|
| 65 |
+
shared metadata and gain SQLAlchemy's mapping machinery.
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
metadata = MetaData(naming_convention=convention)
|
| 69 |
+
|
| 70 |
|
| 71 |
engine = create_engine(
|
| 72 |
config.database_url,
|
|
|
|
| 81 |
class User(Base):
|
| 82 |
__tablename__ = "users"
|
| 83 |
|
| 84 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 85 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 86 |
+
)
|
| 87 |
+
session_id: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
| 88 |
+
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
| 89 |
+
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
|
| 90 |
+
)
|
| 91 |
+
preferred_language: Mapped[Optional[str]] = mapped_column(String(10), default="en")
|
| 92 |
+
first_seen: Mapped[Optional[datetime]] = mapped_column(
|
| 93 |
+
DateTime, default=lambda: datetime.now(timezone.utc)
|
| 94 |
+
)
|
| 95 |
+
last_seen: Mapped[Optional[datetime]] = mapped_column(
|
| 96 |
+
DateTime, default=lambda: datetime.now(timezone.utc)
|
| 97 |
+
)
|
| 98 |
+
total_messages: Mapped[Optional[int]] = mapped_column(Integer, default=0)
|
| 99 |
|
| 100 |
# Relationships
|
| 101 |
+
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="users")
|
| 102 |
+
conversations: Mapped[list["Conversation"]] = relationship(
|
| 103 |
"Conversation", back_populates="user", cascade="all, delete-orphan"
|
| 104 |
)
|
| 105 |
+
preferences: Mapped[list["UserPreference"]] = relationship(
|
| 106 |
"UserPreference", back_populates="user", cascade="all, delete-orphan"
|
| 107 |
)
|
| 108 |
+
insights: Mapped[list["UserInsight"]] = relationship(
|
| 109 |
"UserInsight", back_populates="user", cascade="all, delete-orphan"
|
| 110 |
)
|
|
|
|
| 111 |
|
| 112 |
def __repr__(self):
|
| 113 |
return (
|
|
|
|
| 118 |
class Conversation(Base):
|
| 119 |
__tablename__ = "conversations"
|
| 120 |
|
| 121 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 122 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 123 |
+
)
|
| 124 |
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
| 125 |
+
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
|
| 126 |
+
)
|
| 127 |
+
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
| 128 |
+
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True
|
| 129 |
+
)
|
| 130 |
+
started_at: Mapped[Optional[datetime]] = mapped_column(
|
| 131 |
+
DateTime, default=lambda: datetime.now(timezone.utc)
|
| 132 |
+
)
|
| 133 |
+
last_message_at: Mapped[Optional[datetime]] = mapped_column(
|
| 134 |
+
DateTime, default=lambda: datetime.now(timezone.utc)
|
| 135 |
+
)
|
| 136 |
+
last_message_preview: Mapped[Optional[str]] = mapped_column(
|
| 137 |
+
String(255), nullable=True
|
| 138 |
+
)
|
| 139 |
+
message_count: Mapped[Optional[int]] = mapped_column(Integer, default=0)
|
| 140 |
+
is_active: Mapped[Optional[bool]] = mapped_column(Boolean, default=True)
|
| 141 |
+
context_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
| 142 |
|
| 143 |
# Relationship
|
| 144 |
+
user: Mapped["User"] = relationship("User", back_populates="conversations")
|
| 145 |
+
messages: Mapped[list["Message"]] = relationship(
|
| 146 |
"Message", back_populates="conversation", cascade="all, delete-orphan"
|
| 147 |
)
|
| 148 |
+
topics: Mapped[list["ConversationTopic"]] = relationship(
|
| 149 |
"ConversationTopic", back_populates="conversation", cascade="all, delete-orphan"
|
| 150 |
)
|
| 151 |
+
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="conversations")
|
| 152 |
|
| 153 |
def __repr__(self):
|
| 154 |
return f"<Conversation(id={self.id}, user_id={self.user_id}, messages={self.message_count})>"
|
|
|
|
| 157 |
class Message(Base):
|
| 158 |
__tablename__ = "messages"
|
| 159 |
|
| 160 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 161 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 162 |
+
)
|
| 163 |
+
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
| 164 |
UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=False
|
| 165 |
)
|
| 166 |
|
| 167 |
# Message content
|
| 168 |
+
user_input: Mapped[str] = mapped_column(Text, nullable=False)
|
| 169 |
+
bot_response: Mapped[str] = mapped_column(Text, nullable=False)
|
| 170 |
|
| 171 |
# NLP analysis
|
| 172 |
+
retrieved_doc_ids: Mapped[Optional[list]] = mapped_column(
|
| 173 |
+
JSONB().with_variant(JSON(), "sqlite"), nullable=True
|
| 174 |
+
)
|
| 175 |
+
detected_language: Mapped[Optional[str]] = mapped_column(String(10))
|
| 176 |
+
intent: Mapped[Optional[str]] = mapped_column(String(100))
|
| 177 |
+
confidence: Mapped[Optional[float]] = mapped_column(Float)
|
| 178 |
+
entities: Mapped[Optional[dict]] = mapped_column(
|
| 179 |
+
JSONB().with_variant(JSON(), "sqlite")
|
| 180 |
+
)
|
| 181 |
|
| 182 |
# Metadata
|
| 183 |
+
timestamp: Mapped[Optional[datetime]] = mapped_column(
|
| 184 |
+
DateTime, default=lambda: datetime.now(timezone.utc)
|
| 185 |
+
)
|
| 186 |
+
response_time_ms: Mapped[Optional[int]] = mapped_column(
|
| 187 |
+
Integer
|
| 188 |
+
) # How long the bot took to respond
|
| 189 |
+
|
| 190 |
+
# Feedback
|
| 191 |
+
user_feedback: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=True)
|
| 192 |
+
tenant_feedback: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=True)
|
| 193 |
+
|
| 194 |
+
# AI verdict scores (filled async by judge LLM after each response)
|
| 195 |
+
accuracy_score: Mapped[Optional[float]] = mapped_column(
|
| 196 |
+
Float, nullable=True
|
| 197 |
+
) # factual correctness vs retrieved docs
|
| 198 |
+
relevance_score: Mapped[Optional[float]] = mapped_column(
|
| 199 |
+
Float, nullable=True
|
| 200 |
+
) # how well the response addressed the user query
|
| 201 |
+
safety_score: Mapped[Optional[float]] = mapped_column(
|
| 202 |
+
Float, nullable=True
|
| 203 |
+
) # did bot stay on-domain and avoid harmful/misleading claims (0.0 = unsafe, 1.0 = safe)
|
| 204 |
|
| 205 |
# Relationships
|
| 206 |
+
conversation: Mapped["Conversation"] = relationship(
|
| 207 |
+
"Conversation", back_populates="messages"
|
| 208 |
+
)
|
| 209 |
|
| 210 |
def __repr__(self):
|
| 211 |
return f"<Message(id={self.id}, intent={self.intent}, confidence={self.confidence})>"
|
| 212 |
|
| 213 |
|
| 214 |
+
class Tenant(Base):
|
| 215 |
+
__tablename__ = "tenants"
|
| 216 |
+
__table_args__ = (
|
| 217 |
+
CheckConstraint("billing_mode IN ('credits', 'subscription', 'gifted')", name="chk_tenant_billing_mode"),
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 221 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 222 |
+
)
|
| 223 |
+
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 224 |
+
website_url: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
| 225 |
+
api_key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
| 226 |
+
is_active: Mapped[Optional[bool]] = mapped_column(Boolean, default=True)
|
| 227 |
+
created_at: Mapped[Optional[datetime]] = mapped_column(
|
| 228 |
+
DateTime, default=lambda: datetime.now(timezone.utc)
|
| 229 |
+
)
|
| 230 |
+
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
| 231 |
+
DateTime,
|
| 232 |
+
default=lambda: datetime.now(timezone.utc),
|
| 233 |
+
onupdate=lambda: datetime.now(timezone.utc),
|
| 234 |
+
)
|
| 235 |
+
customization: Mapped[Optional[dict]] = mapped_column(
|
| 236 |
+
JSONB().with_variant(JSON(), "sqlite"), nullable=True
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
# Admin panel auth fields
|
| 240 |
+
owner_email: Mapped[Optional[str]] = mapped_column(
|
| 241 |
+
String(255), nullable=True, unique=True
|
| 242 |
+
)
|
| 243 |
+
google_id: Mapped[Optional[str]] = mapped_column(
|
| 244 |
+
String(255), nullable=True, unique=True
|
| 245 |
+
)
|
| 246 |
+
# Stores Fernet-encrypted TOTP seed — never the raw secret
|
| 247 |
+
totp_secret: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
| 248 |
+
totp_enabled: Mapped[Optional[bool]] = mapped_column(Boolean, default=False)
|
| 249 |
+
# Stores JSON array of SHA-256-hashed backup codes (plaintext shown once at setup)
|
| 250 |
+
backup_codes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
| 251 |
+
|
| 252 |
+
backup_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
| 253 |
+
phone: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
| 254 |
+
|
| 255 |
+
# Super-admin controls
|
| 256 |
+
is_blocked: Mapped[bool] = mapped_column(
|
| 257 |
+
Boolean, default=False, nullable=False, server_default="false"
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
# Billing
|
| 261 |
+
balance_usd: Mapped[Decimal] = mapped_column(
|
| 262 |
+
Numeric(20, 6), nullable=False, server_default="0"
|
| 263 |
+
)
|
| 264 |
+
billing_mode: Mapped[str] = mapped_column(
|
| 265 |
+
String(20), nullable=False, server_default="credits"
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
# Relationships
|
| 269 |
+
users: Mapped[list["User"]] = relationship("User", back_populates="tenant")
|
| 270 |
+
conversations: Mapped[list["Conversation"]] = relationship(
|
| 271 |
+
"Conversation", back_populates="tenant"
|
| 272 |
+
)
|
| 273 |
+
credit_events: Mapped[list["CreditEvent"]] = relationship(
|
| 274 |
+
"CreditEvent", back_populates="tenant"
|
| 275 |
+
)
|
| 276 |
+
auth_providers: Mapped[list["TenantAuthProvider"]] = relationship(
|
| 277 |
+
"TenantAuthProvider", back_populates="tenant", cascade="all, delete-orphan"
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
def __repr__(self):
|
| 281 |
+
return f"<Tenant(id={self.id} name={self.name})>"
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
class LLMModel(Base):
|
| 285 |
+
__tablename__ = "llm_models"
|
| 286 |
+
|
| 287 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 288 |
+
provider: Mapped[str] = mapped_column(String(100), nullable=False)
|
| 289 |
+
model_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 290 |
+
usd_per_1k_input: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False)
|
| 291 |
+
usd_per_1k_output: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False)
|
| 292 |
+
effective_from: Mapped[date] = mapped_column(Date, nullable=False)
|
| 293 |
+
effective_to: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
| 294 |
+
|
| 295 |
+
def __repr__(self):
|
| 296 |
+
return f"<LLMModel(provider={self.provider}, model={self.model_name}, from={self.effective_from})>"
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
class CreditEvent(Base):
|
| 300 |
+
__tablename__ = "credit_events"
|
| 301 |
+
__table_args__ = (
|
| 302 |
+
UniqueConstraint(
|
| 303 |
+
"tenant_id", "request_id", name="uq_credit_events_tenant_id_request_id"
|
| 304 |
+
),
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 308 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 309 |
+
)
|
| 310 |
+
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
| 311 |
+
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False, index=True
|
| 312 |
+
)
|
| 313 |
+
event_type: Mapped[str] = mapped_column(String(20), nullable=False) # "deduct" | "topup"
|
| 314 |
+
amount: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False)
|
| 315 |
+
balance_after: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False)
|
| 316 |
+
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
| 317 |
+
created_by: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
| 318 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 319 |
+
DateTime, default=lambda: datetime.now(timezone.utc)
|
| 320 |
+
)
|
| 321 |
+
request_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
| 322 |
+
llm_model_id: Mapped[Optional[int]] = mapped_column(
|
| 323 |
+
Integer, ForeignKey("llm_models.id"), nullable=True
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
# Relationships
|
| 327 |
+
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="credit_events")
|
| 328 |
+
llm_model: Mapped[Optional["LLMModel"]] = relationship("LLMModel")
|
| 329 |
+
|
| 330 |
+
def __repr__(self):
|
| 331 |
+
return f"<CreditEvent(tenant_id={self.tenant_id}, type={self.event_type}, amount={self.amount})>"
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
class TenantAuthProvider(Base):
|
| 335 |
+
__tablename__ = "tenant_auth_providers"
|
| 336 |
+
__table_args__ = (
|
| 337 |
+
UniqueConstraint("tenant_id", "provider", name="uq_tenant_auth_providers_tenant_id_provider"),
|
| 338 |
+
CheckConstraint("provider IN ('google', 'telegram')", name="chk_tenant_auth_providers_provider"),
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 342 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 343 |
+
)
|
| 344 |
+
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
| 345 |
+
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True
|
| 346 |
+
)
|
| 347 |
+
provider: Mapped[str] = mapped_column(String(20), nullable=False)
|
| 348 |
+
provider_user_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 349 |
+
linked_at: Mapped[datetime] = mapped_column(
|
| 350 |
+
DateTime, default=lambda: datetime.now(timezone.utc)
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="auth_providers")
|
| 354 |
+
|
| 355 |
+
def __repr__(self):
|
| 356 |
+
return f"<TenantAuthProvider(tenant_id={self.tenant_id}, provider={self.provider})>"
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
class SummarizationJob(Base):
|
| 360 |
+
__tablename__ = "summarization_jobs"
|
| 361 |
+
__table_args__ = (
|
| 362 |
+
Index(
|
| 363 |
+
"uq_summarization_jobs_pending",
|
| 364 |
+
"conversation_id",
|
| 365 |
+
unique=True,
|
| 366 |
+
postgresql_where=text("status = 'pending'"),
|
| 367 |
+
),
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 371 |
+
UUID(as_uuid=True),
|
| 372 |
+
primary_key=True,
|
| 373 |
+
default=uuid.uuid4,
|
| 374 |
+
)
|
| 375 |
+
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
| 376 |
+
UUID(as_uuid=True),
|
| 377 |
+
ForeignKey("conversations.id"),
|
| 378 |
+
nullable=False,
|
| 379 |
+
index=True,
|
| 380 |
+
)
|
| 381 |
+
status: Mapped[str] = mapped_column(
|
| 382 |
+
String(20),
|
| 383 |
+
default="pending",
|
| 384 |
+
)
|
| 385 |
+
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
| 386 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 387 |
+
DateTime,
|
| 388 |
+
default=lambda: datetime.now(timezone.utc),
|
| 389 |
+
)
|
| 390 |
+
last_attempted_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
class Industry(Base):
|
| 394 |
+
__tablename__ = "industries"
|
| 395 |
+
|
| 396 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 397 |
+
code: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
| 398 |
+
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
| 399 |
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
| 400 |
+
|
| 401 |
+
def __repr__(self):
|
| 402 |
+
return f"<Industry(code={self.code}, display_name={self.display_name}, is_active={self.is_active})>"
|
| 403 |
+
|
| 404 |
+
|
| 405 |
def init_database():
|
| 406 |
+
"""Create all tables on SQLite (HF Spaces / tests). PostgreSQL uses Alembic migrations only."""
|
| 407 |
+
if engine.dialect.name == "sqlite":
|
| 408 |
+
Base.metadata.create_all(bind=engine)
|
| 409 |
+
logger.info("Database tables created successfully (SQLite)")
|
| 410 |
+
else:
|
| 411 |
+
logger.info("PostgreSQL detected — skipping create_all(); use 'alembic upgrade head' to apply migrations")
|
| 412 |
|
| 413 |
|
| 414 |
def health_check():
|
|
|
|
| 426 |
|
| 427 |
|
| 428 |
def get_db():
|
| 429 |
+
"""FastAPI dependency that provides a database session per request.
|
| 430 |
+
|
| 431 |
+
Yields a session and guarantees it is closed after the request finishes,
|
| 432 |
+
whether or not an exception occurred. Inject with `db: Session = Depends(get_db)`.
|
| 433 |
+
"""
|
| 434 |
db = SessionLocal()
|
| 435 |
try:
|
| 436 |
yield db
|
app/models/information_extractor.py
CHANGED
|
@@ -50,7 +50,7 @@ class InformationExtractor:
|
|
| 50 |
except OSError:
|
| 51 |
return False
|
| 52 |
|
| 53 |
-
def _build_patterns(self) ->
|
| 54 |
"""Build regex patterns for information extraction"""
|
| 55 |
return {
|
| 56 |
# Name patterns
|
|
@@ -157,14 +157,16 @@ class InformationExtractor:
|
|
| 157 |
"""Extract product interests and preferences"""
|
| 158 |
interests = []
|
| 159 |
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
| 168 |
|
| 169 |
# Alternative approach - direct pattern matching
|
| 170 |
product_categories = {
|
|
@@ -336,6 +338,6 @@ class InformationExtractor:
|
|
| 336 |
|
| 337 |
# Convert sets to lists for JSON serialization
|
| 338 |
summary["topics_discussed"] = list(summary["topics_discussed"])
|
| 339 |
-
summary["user_interests"] =
|
| 340 |
|
| 341 |
return summary
|
|
|
|
| 50 |
except OSError:
|
| 51 |
return False
|
| 52 |
|
| 53 |
+
def _build_patterns(self) -> dict[str, list[str] | dict[str, list[str]]]:
|
| 54 |
"""Build regex patterns for information extraction"""
|
| 55 |
return {
|
| 56 |
# Name patterns
|
|
|
|
| 157 |
"""Extract product interests and preferences"""
|
| 158 |
interests = []
|
| 159 |
|
| 160 |
+
product_mentions = self.patterns["product_mentions"]
|
| 161 |
+
if isinstance(product_mentions, dict):
|
| 162 |
+
for category, patterns in product_mentions.items():
|
| 163 |
+
for pattern in patterns:
|
| 164 |
+
if re.search(pattern, message, re.IGNORECASE):
|
| 165 |
+
category_name = category.split("_", maxsplit=1)[
|
| 166 |
+
0
|
| 167 |
+
] # "product_x_y" -> "product", "x_y" -> "product"
|
| 168 |
+
if category_name not in interests:
|
| 169 |
+
interests.append(category_name)
|
| 170 |
|
| 171 |
# Alternative approach - direct pattern matching
|
| 172 |
product_categories = {
|
|
|
|
| 338 |
|
| 339 |
# Convert sets to lists for JSON serialization
|
| 340 |
summary["topics_discussed"] = list(summary["topics_discussed"])
|
| 341 |
+
summary["user_interests"] = list(summary["user_interests"])
|
| 342 |
|
| 343 |
return summary
|
app/models/smart_models.py
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from datetime import datetime, timezone
|
|
|
|
| 2 |
import uuid
|
| 3 |
from sqlalchemy import (
|
| 4 |
-
|
| 5 |
String,
|
| 6 |
Text,
|
| 7 |
DateTime,
|
|
@@ -10,139 +22,252 @@ from sqlalchemy import (
|
|
| 10 |
Boolean,
|
| 11 |
ForeignKey,
|
| 12 |
)
|
| 13 |
-
from sqlalchemy.orm import relationship
|
| 14 |
from sqlalchemy.dialects.postgresql import UUID
|
| 15 |
|
| 16 |
# Absolute import from project root
|
| 17 |
from app.models.database import Base
|
| 18 |
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
class UserPreference(Base):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
__tablename__ = "user_preferences"
|
| 22 |
|
| 23 |
-
id
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
# Preference details
|
| 27 |
-
preference_type =
|
| 28 |
String(50), nullable=False
|
| 29 |
) # e.g., "name", "product_interest", "communication_style"
|
| 30 |
-
preference_key =
|
| 31 |
String(100), nullable=False
|
| 32 |
) # e.g., "user_name", "preferred_products", "formality_level"
|
| 33 |
-
preference_value =
|
| 34 |
Text, nullable=False
|
| 35 |
) # e.g., "John", "toys,gifts", "casual"
|
| 36 |
|
| 37 |
# Learning metadata
|
| 38 |
-
confidence_score =
|
| 39 |
Float, default=1.0
|
| 40 |
) # How confident the model is (0.0-1.0)
|
| 41 |
-
learned_from_messages =
|
| 42 |
Integer, default=1
|
| 43 |
) # Number of messages that taught us this
|
| 44 |
-
first_learned
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
# Relationship
|
| 48 |
-
user = relationship("User", back_populates="preferences")
|
| 49 |
|
| 50 |
def __repr__(self):
|
| 51 |
return f"<UserPreference(id={self.id} user_id={self.user_id} {self.preference_key}={self.preference_value})>"
|
| 52 |
|
| 53 |
|
| 54 |
class ConversationTopic(Base):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
__tablename__ = "conversation_topics"
|
| 56 |
|
| 57 |
-
id
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=False
|
| 60 |
)
|
| 61 |
|
| 62 |
# Topic details
|
| 63 |
-
topic =
|
| 64 |
String(100), nullable=False
|
| 65 |
) # "product_inquiry", "order_management", "support"
|
| 66 |
-
subtopic =
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
# Topic metadata
|
| 70 |
-
first_mentioned
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
Float, default=1.0
|
| 74 |
) # How important this topic was (0.0-1.0)
|
| 75 |
|
| 76 |
# Relationships
|
| 77 |
-
conversation
|
|
|
|
|
|
|
| 78 |
|
| 79 |
def __repr__(self):
|
| 80 |
return f"<ConversationTopic(topic={self.topic}, subtopic={self.subtopic})>"
|
| 81 |
|
| 82 |
|
| 83 |
class UserInsight(Base):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
__tablename__ = "user_insights"
|
| 85 |
|
| 86 |
-
id
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
# Insight details
|
| 90 |
-
insight_type =
|
| 91 |
String(50), nullable=False
|
| 92 |
) # "behavior_pattern", "preference_trend", "interaction_style"
|
| 93 |
-
insight_key =
|
| 94 |
String(100), nullable=False
|
| 95 |
) # "most_active_time", "preferred_topics", "question_complexity"
|
| 96 |
-
insight_value
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
# Analytics metadata
|
| 100 |
-
confidence_level
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
# Relationships
|
| 106 |
-
user = relationship("User", back_populates="insights")
|
| 107 |
|
| 108 |
def __repr__(self):
|
| 109 |
return f"<UserInsight(id={self.id} user_id={self.user_id}, {self.insight_key}={self.insight_value})>"
|
| 110 |
|
| 111 |
|
| 112 |
class KnowledgeBase(Base):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
__tablename__ = "knowledge_base"
|
| 114 |
|
| 115 |
-
id
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
DateTime,
|
| 119 |
default=lambda: datetime.now(timezone.utc),
|
| 120 |
onupdate=lambda: datetime.now(timezone.utc),
|
| 121 |
)
|
| 122 |
-
tenant_id
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
def __repr__(self):
|
| 126 |
return f"<KnowledgeBase(id={self.id} tenant_id={self.tenant_id})>"
|
| 127 |
|
| 128 |
|
| 129 |
-
class
|
| 130 |
-
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
| 138 |
DateTime,
|
| 139 |
default=lambda: datetime.now(timezone.utc),
|
| 140 |
onupdate=lambda: datetime.now(timezone.utc),
|
| 141 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
def __repr__(self):
|
| 148 |
-
return f"<
|
|
|
|
| 1 |
+
"""ORM models for the smart layer — features that learn from and adapt to users.
|
| 2 |
+
|
| 3 |
+
These models are separate from the core chatbot models (User, Conversation, Message)
|
| 4 |
+
in database.py. They extend the system with intelligence: tracking what users prefer,
|
| 5 |
+
what topics they discuss, what behavioral patterns emerge, and what knowledge the
|
| 6 |
+
tenant has loaded into the system.
|
| 7 |
+
|
| 8 |
+
All models inherit from Base defined in database.py and are imported in main.py to
|
| 9 |
+
ensure their tables are registered with Base.metadata before init_database() runs.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
from datetime import datetime, timezone
|
| 13 |
+
from typing import Optional, TYPE_CHECKING
|
| 14 |
import uuid
|
| 15 |
from sqlalchemy import (
|
| 16 |
+
CheckConstraint,
|
| 17 |
String,
|
| 18 |
Text,
|
| 19 |
DateTime,
|
|
|
|
| 22 |
Boolean,
|
| 23 |
ForeignKey,
|
| 24 |
)
|
| 25 |
+
from sqlalchemy.orm import relationship, mapped_column, Mapped
|
| 26 |
from sqlalchemy.dialects.postgresql import UUID
|
| 27 |
|
| 28 |
# Absolute import from project root
|
| 29 |
from app.models.database import Base
|
| 30 |
|
| 31 |
+
if TYPE_CHECKING:
|
| 32 |
+
from app.models.database import User, Conversation
|
| 33 |
+
|
| 34 |
|
| 35 |
class UserPreference(Base):
|
| 36 |
+
"""Stores a single learned preference for a user.
|
| 37 |
+
|
| 38 |
+
Each row represents one thing the system has inferred about a user —
|
| 39 |
+
for example, their name, a product category they care about, or their
|
| 40 |
+
preferred communication style. A user can have many preferences, each
|
| 41 |
+
identified by a (preference_type, preference_key) pair.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
__tablename__ = "user_preferences"
|
| 45 |
|
| 46 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 47 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 48 |
+
)
|
| 49 |
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
| 50 |
+
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
|
| 51 |
+
)
|
| 52 |
|
| 53 |
# Preference details
|
| 54 |
+
preference_type: Mapped[str] = mapped_column(
|
| 55 |
String(50), nullable=False
|
| 56 |
) # e.g., "name", "product_interest", "communication_style"
|
| 57 |
+
preference_key: Mapped[str] = mapped_column(
|
| 58 |
String(100), nullable=False
|
| 59 |
) # e.g., "user_name", "preferred_products", "formality_level"
|
| 60 |
+
preference_value: Mapped[str] = mapped_column(
|
| 61 |
Text, nullable=False
|
| 62 |
) # e.g., "John", "toys,gifts", "casual"
|
| 63 |
|
| 64 |
# Learning metadata
|
| 65 |
+
confidence_score: Mapped[Optional[float]] = mapped_column(
|
| 66 |
Float, default=1.0
|
| 67 |
) # How confident the model is (0.0-1.0)
|
| 68 |
+
learned_from_messages: Mapped[Optional[int]] = mapped_column(
|
| 69 |
Integer, default=1
|
| 70 |
) # Number of messages that taught us this
|
| 71 |
+
first_learned: Mapped[Optional[datetime]] = mapped_column(
|
| 72 |
+
DateTime, default=lambda: datetime.now(timezone.utc)
|
| 73 |
+
)
|
| 74 |
+
last_updated: Mapped[Optional[datetime]] = mapped_column(
|
| 75 |
+
DateTime, default=lambda: datetime.now(timezone.utc)
|
| 76 |
+
)
|
| 77 |
|
| 78 |
# Relationship
|
| 79 |
+
user: Mapped["User"] = relationship("User", back_populates="preferences")
|
| 80 |
|
| 81 |
def __repr__(self):
|
| 82 |
return f"<UserPreference(id={self.id} user_id={self.user_id} {self.preference_key}={self.preference_value})>"
|
| 83 |
|
| 84 |
|
| 85 |
class ConversationTopic(Base):
|
| 86 |
+
"""Tracks what topics were discussed within a single conversation.
|
| 87 |
+
|
| 88 |
+
Each row tags a conversation with a topic (e.g. "product_inquiry") and
|
| 89 |
+
optional subtopic (e.g. "toys"). Used to understand what a conversation
|
| 90 |
+
was about without re-reading its messages, enabling routing, analytics,
|
| 91 |
+
and context-aware follow-ups.
|
| 92 |
+
"""
|
| 93 |
+
|
| 94 |
__tablename__ = "conversation_topics"
|
| 95 |
|
| 96 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 97 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 98 |
+
)
|
| 99 |
+
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
| 100 |
UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=False
|
| 101 |
)
|
| 102 |
|
| 103 |
# Topic details
|
| 104 |
+
topic: Mapped[str] = mapped_column(
|
| 105 |
String(100), nullable=False
|
| 106 |
) # "product_inquiry", "order_management", "support"
|
| 107 |
+
subtopic: Mapped[Optional[str]] = mapped_column(
|
| 108 |
+
String(100)
|
| 109 |
+
) # "toys", "cancellation", "shipping_issue"
|
| 110 |
+
keywords: Mapped[Optional[str]] = mapped_column(
|
| 111 |
+
Text
|
| 112 |
+
) # JSON list of relevant keywords
|
| 113 |
|
| 114 |
# Topic metadata
|
| 115 |
+
first_mentioned: Mapped[Optional[datetime]] = mapped_column(
|
| 116 |
+
DateTime, default=lambda: datetime.now(timezone.utc)
|
| 117 |
+
)
|
| 118 |
+
message_count: Mapped[Optional[int]] = mapped_column(
|
| 119 |
+
Integer, default=1
|
| 120 |
+
) # How many messages discussed this topic
|
| 121 |
+
importance_score: Mapped[Optional[float]] = mapped_column(
|
| 122 |
Float, default=1.0
|
| 123 |
) # How important this topic was (0.0-1.0)
|
| 124 |
|
| 125 |
# Relationships
|
| 126 |
+
conversation: Mapped["Conversation"] = relationship(
|
| 127 |
+
"Conversation", back_populates="topics"
|
| 128 |
+
)
|
| 129 |
|
| 130 |
def __repr__(self):
|
| 131 |
return f"<ConversationTopic(topic={self.topic}, subtopic={self.subtopic})>"
|
| 132 |
|
| 133 |
|
| 134 |
class UserInsight(Base):
|
| 135 |
+
"""Stores higher-level behavioral patterns derived from a user's history.
|
| 136 |
+
|
| 137 |
+
Unlike UserPreference (which records stated or inferred facts), UserInsight
|
| 138 |
+
records computed patterns — e.g. "most active in the morning", "asks complex
|
| 139 |
+
questions", "prefers toys category". Insights are recalculated over time and
|
| 140 |
+
can be deactivated when they are no longer supported by recent data.
|
| 141 |
+
"""
|
| 142 |
+
|
| 143 |
__tablename__ = "user_insights"
|
| 144 |
|
| 145 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 146 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 147 |
+
)
|
| 148 |
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
| 149 |
+
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
|
| 150 |
+
)
|
| 151 |
|
| 152 |
# Insight details
|
| 153 |
+
insight_type: Mapped[str] = mapped_column(
|
| 154 |
String(50), nullable=False
|
| 155 |
) # "behavior_pattern", "preference_trend", "interaction_style"
|
| 156 |
+
insight_key: Mapped[str] = mapped_column(
|
| 157 |
String(100), nullable=False
|
| 158 |
) # "most_active_time", "preferred_topics", "question_complexity"
|
| 159 |
+
insight_value: Mapped[str] = mapped_column(
|
| 160 |
+
Text, nullable=False
|
| 161 |
+
) # "morning", "toys,gifts", "simple"
|
| 162 |
+
insight_description: Mapped[Optional[str]] = mapped_column(
|
| 163 |
+
Text
|
| 164 |
+
) # Human-readable explanation
|
| 165 |
|
| 166 |
# Analytics metadata
|
| 167 |
+
confidence_level: Mapped[Optional[float]] = mapped_column(
|
| 168 |
+
Float, default=1.0
|
| 169 |
+
) # Statistical confidence (0.0-1.0)
|
| 170 |
+
based_on_messages: Mapped[Optional[int]] = mapped_column(
|
| 171 |
+
Integer, default=0
|
| 172 |
+
) # Number of messages analyzed
|
| 173 |
+
last_calculated: Mapped[Optional[datetime]] = mapped_column(
|
| 174 |
+
DateTime, default=lambda: datetime.now(timezone.utc)
|
| 175 |
+
)
|
| 176 |
+
is_active: Mapped[Optional[bool]] = mapped_column(
|
| 177 |
+
Boolean, default=True
|
| 178 |
+
) # Is this insight still relevant?
|
| 179 |
|
| 180 |
# Relationships
|
| 181 |
+
user: Mapped["User"] = relationship("User", back_populates="insights")
|
| 182 |
|
| 183 |
def __repr__(self):
|
| 184 |
return f"<UserInsight(id={self.id} user_id={self.user_id}, {self.insight_key}={self.insight_value})>"
|
| 185 |
|
| 186 |
|
| 187 |
class KnowledgeBase(Base):
|
| 188 |
+
"""Stores tenant-specific knowledge entries used to ground the chatbot's responses.
|
| 189 |
+
|
| 190 |
+
Each row is a plain-text passage belonging to a tenant. The chatbot retrieves
|
| 191 |
+
relevant entries at query time (RAG) and includes them in the LLM prompt so
|
| 192 |
+
responses are based on the tenant's actual content rather than general knowledge.
|
| 193 |
+
"""
|
| 194 |
+
|
| 195 |
__tablename__ = "knowledge_base"
|
| 196 |
|
| 197 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 198 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 199 |
+
)
|
| 200 |
+
created_at: Mapped[Optional[datetime]] = mapped_column(
|
| 201 |
+
DateTime, default=lambda: datetime.now(timezone.utc)
|
| 202 |
+
)
|
| 203 |
+
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
| 204 |
DateTime,
|
| 205 |
default=lambda: datetime.now(timezone.utc),
|
| 206 |
onupdate=lambda: datetime.now(timezone.utc),
|
| 207 |
)
|
| 208 |
+
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
| 209 |
+
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
|
| 210 |
+
)
|
| 211 |
+
title: Mapped[str] = mapped_column(String(255), nullable=False, server_default="Untitled")
|
| 212 |
+
content: Mapped[str] = mapped_column(Text, nullable=False)
|
| 213 |
+
source_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
| 214 |
+
origin: Mapped[str] = mapped_column(String(20), nullable=False, server_default="manual")
|
| 215 |
+
is_indexed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
| 216 |
+
|
| 217 |
+
__table_args__ = (
|
| 218 |
+
CheckConstraint("origin IN ('manual', 'synced')", name="kb_origin_valid"),
|
| 219 |
+
)
|
| 220 |
|
| 221 |
def __repr__(self):
|
| 222 |
return f"<KnowledgeBase(id={self.id} tenant_id={self.tenant_id})>"
|
| 223 |
|
| 224 |
|
| 225 |
+
class SyncJob(Base):
|
| 226 |
+
"""Tracks tenant-owned WordPress sync progress for async background jobs."""
|
| 227 |
|
| 228 |
+
__tablename__ = "sync_jobs"
|
| 229 |
+
|
| 230 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 231 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 232 |
+
)
|
| 233 |
+
created_at: Mapped[Optional[datetime]] = mapped_column(
|
| 234 |
+
DateTime, default=lambda: datetime.now(timezone.utc)
|
| 235 |
+
)
|
| 236 |
+
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
| 237 |
DateTime,
|
| 238 |
default=lambda: datetime.now(timezone.utc),
|
| 239 |
onupdate=lambda: datetime.now(timezone.utc),
|
| 240 |
)
|
| 241 |
+
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
| 242 |
+
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
|
| 243 |
+
)
|
| 244 |
+
source_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
| 245 |
+
rest_base: Mapped[str] = mapped_column(String(50), nullable=False, default="posts", server_default="posts")
|
| 246 |
+
status: Mapped[str] = mapped_column(String(30), nullable=False, server_default="queued")
|
| 247 |
+
total: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
| 248 |
+
processed: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
| 249 |
+
failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
| 250 |
+
created: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
| 251 |
+
updated: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
| 252 |
+
skipped: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
| 253 |
+
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
| 254 |
|
| 255 |
+
__table_args__ = (
|
| 256 |
+
CheckConstraint(
|
| 257 |
+
"status IN ('cancelled', 'completed', 'expired', 'failed', 'paused', 'queued', 'running')",
|
| 258 |
+
name="sync_jobs_status_valid",
|
| 259 |
+
),
|
| 260 |
+
CheckConstraint("total >= 0", name="sync_jobs_total_nonnegative"),
|
| 261 |
+
CheckConstraint("processed >= 0", name="sync_jobs_processed_nonnegative"),
|
| 262 |
+
CheckConstraint("failed >= 0", name="sync_jobs_failed_nonnegative"),
|
| 263 |
+
CheckConstraint(
|
| 264 |
+
"processed + failed <= total",
|
| 265 |
+
name="sync_jobs_progress_within_total",
|
| 266 |
+
),
|
| 267 |
+
CheckConstraint("created >= 0", name="sync_jobs_created_nonnegative"),
|
| 268 |
+
CheckConstraint("updated >= 0", name="sync_jobs_updated_nonnegative"),
|
| 269 |
+
CheckConstraint("skipped >= 0", name="sync_jobs_skipped_nonnegative"),
|
| 270 |
+
)
|
| 271 |
|
| 272 |
def __repr__(self):
|
| 273 |
+
return f"<SyncJob(id={self.id} tenant_id={self.tenant_id} status={self.status})>"
|
app/routers/admin_apikey.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy import select
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from app.models.database import get_db, Tenant
|
| 5 |
+
from app.dependencies.admin_auth import require_admin_jwt
|
| 6 |
+
from app.schemas.admin_apikey import ApiKeyRotateResponse
|
| 7 |
+
from app.utils.api_key import generate_api_key
|
| 8 |
+
import uuid
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/admin", tags=["admin"])
|
| 11 |
+
|
| 12 |
+
@router.post("/api-key/rotate", response_model=ApiKeyRotateResponse)
|
| 13 |
+
def rotate_api_key(
|
| 14 |
+
tenant_id: uuid.UUID = Depends(require_admin_jwt),
|
| 15 |
+
db: Session = Depends(get_db)
|
| 16 |
+
):
|
| 17 |
+
"""
|
| 18 |
+
Generates a new API key, stores it hashed, and returns the plaintext exactly once.
|
| 19 |
+
"""
|
| 20 |
+
tenant = db.scalar(select(Tenant).where(Tenant.id == tenant_id))
|
| 21 |
+
if not tenant:
|
| 22 |
+
raise HTTPException(status_code=404, detail="Tenant not found")
|
| 23 |
+
|
| 24 |
+
plaintext, hashed = generate_api_key()
|
| 25 |
+
tenant.api_key = hashed
|
| 26 |
+
db.commit()
|
| 27 |
+
|
| 28 |
+
return {"new_api_key": plaintext}
|
app/routers/admin_attention.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends
|
| 4 |
+
from sqlalchemy import func, select
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
|
| 7 |
+
from app.dependencies.admin_auth import require_admin_jwt
|
| 8 |
+
from app.models.database import get_db
|
| 9 |
+
from app.models.smart_models import KnowledgeBase
|
| 10 |
+
from app.schemas.admin import AttentionResponse
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/admin", tags=["admin"])
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@router.get("/attention", response_model=AttentionResponse)
|
| 16 |
+
def get_admin_attention(
|
| 17 |
+
tenant_id: uuid.UUID = Depends(require_admin_jwt),
|
| 18 |
+
db: Session = Depends(get_db),
|
| 19 |
+
) -> AttentionResponse:
|
| 20 |
+
count = db.scalar(
|
| 21 |
+
select(func.count()).where(
|
| 22 |
+
KnowledgeBase.tenant_id == tenant_id,
|
| 23 |
+
KnowledgeBase.is_indexed == False, # noqa: E712
|
| 24 |
+
)
|
| 25 |
+
)
|
| 26 |
+
return AttentionResponse(kb_has_unindexed=(count or 0) > 0)
|
app/routers/admin_auth.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import hmac as hmac_mod
|
| 3 |
+
import re
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
| 9 |
+
from fastapi.responses import JSONResponse
|
| 10 |
+
from pydantic import BaseModel
|
| 11 |
+
from sqlalchemy import select
|
| 12 |
+
from sqlalchemy.orm import Session
|
| 13 |
+
|
| 14 |
+
from app.dependencies.admin_auth import require_admin_jwt
|
| 15 |
+
from app.models.database import Tenant, TenantAuthProvider, get_db
|
| 16 |
+
from app.services.google_auth import (
|
| 17 |
+
decode_partial_jwt,
|
| 18 |
+
issue_full_jwt,
|
| 19 |
+
issue_partial_jwt,
|
| 20 |
+
lookup_and_bind_tenant,
|
| 21 |
+
verify_google_token,
|
| 22 |
+
)
|
| 23 |
+
from app.services.totp_service import (
|
| 24 |
+
consume_backup_code,
|
| 25 |
+
encode_backup_codes,
|
| 26 |
+
encrypt_secret,
|
| 27 |
+
generate_backup_codes,
|
| 28 |
+
generate_totp_secret,
|
| 29 |
+
hash_backup_code,
|
| 30 |
+
make_provisioning_uri,
|
| 31 |
+
verify_totp_code,
|
| 32 |
+
)
|
| 33 |
+
from app.utils.config import get_config
|
| 34 |
+
|
| 35 |
+
config = get_config()
|
| 36 |
+
router = APIRouter(prefix="/admin/auth", tags=["admin-auth"])
|
| 37 |
+
|
| 38 |
+
_TOTP_RE = re.compile(r"^\d{6}$")
|
| 39 |
+
_BACKUP_RE = re.compile(r"^[0-9a-f]{16}$")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class GoogleAuthRequest(BaseModel):
|
| 43 |
+
id_token: str
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class GoogleAuthResponse(BaseModel):
|
| 47 |
+
totp_enabled: bool
|
| 48 |
+
partial_token: Optional[str] = None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class TOTPSetupResponse(BaseModel):
|
| 52 |
+
provisioning_uri: str
|
| 53 |
+
backup_codes: list[str] # plaintext — shown once, not retrievable again
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class TOTPVerifyRequest(BaseModel):
|
| 57 |
+
code: str
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class TelegramAuthRequest(BaseModel):
|
| 61 |
+
id: int
|
| 62 |
+
first_name: str
|
| 63 |
+
last_name: Optional[str] = None
|
| 64 |
+
username: Optional[str] = None
|
| 65 |
+
photo_url: Optional[str] = None
|
| 66 |
+
auth_date: int
|
| 67 |
+
hash: str
|
| 68 |
+
phone_number: Optional[str] = None
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _verify_telegram_hmac(data: dict, bot_token: str) -> bool:
|
| 72 |
+
"""Verify Telegram Login Widget data using HMAC-SHA256."""
|
| 73 |
+
received_hash = data.get("hash", "")
|
| 74 |
+
check_pairs = sorted(
|
| 75 |
+
(k, str(v)) for k, v in data.items() if k != "hash" and v is not None
|
| 76 |
+
)
|
| 77 |
+
data_check_string = "\n".join(f"{k}={v}" for k, v in check_pairs)
|
| 78 |
+
secret_key = hashlib.sha256(bot_token.encode()).digest()
|
| 79 |
+
expected_hash = hmac_mod.new(
|
| 80 |
+
secret_key, data_check_string.encode(), hashlib.sha256
|
| 81 |
+
).hexdigest()
|
| 82 |
+
return hmac_mod.compare_digest(expected_hash, received_hash)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _set_access_cookie(response: Response, tenant_id: uuid.UUID) -> None:
|
| 86 |
+
"""Issue a full JWT and set it as an HttpOnly cookie on the response."""
|
| 87 |
+
access_token = issue_full_jwt(tenant_id, config.jwt_secret_key)
|
| 88 |
+
response.set_cookie(
|
| 89 |
+
key="access_token",
|
| 90 |
+
value=access_token,
|
| 91 |
+
httponly=True,
|
| 92 |
+
secure=config.env.name == "production",
|
| 93 |
+
samesite="strict",
|
| 94 |
+
path="/api",
|
| 95 |
+
max_age=86400,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@router.post("/telegram")
|
| 100 |
+
async def telegram_auth(body: TelegramAuthRequest, db: Session = Depends(get_db)) -> JSONResponse:
|
| 101 |
+
# 1. Guard: bot token must be configured
|
| 102 |
+
if not config.telegram_bot_token:
|
| 103 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 104 |
+
|
| 105 |
+
# 2. Verify HMAC
|
| 106 |
+
data = body.model_dump(exclude_none=True)
|
| 107 |
+
if not _verify_telegram_hmac(data, config.telegram_bot_token):
|
| 108 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 109 |
+
|
| 110 |
+
# 3. Check auth_date freshness (24 hours)
|
| 111 |
+
if time.time() - body.auth_date > 86400:
|
| 112 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 113 |
+
|
| 114 |
+
telegram_user_id = str(body.id)
|
| 115 |
+
phone = body.phone_number
|
| 116 |
+
|
| 117 |
+
# 3. Try direct match via auth_providers
|
| 118 |
+
ap_row = db.scalars(
|
| 119 |
+
select(TenantAuthProvider).where(
|
| 120 |
+
TenantAuthProvider.provider == "telegram",
|
| 121 |
+
TenantAuthProvider.provider_user_id == telegram_user_id,
|
| 122 |
+
)
|
| 123 |
+
).first()
|
| 124 |
+
|
| 125 |
+
if ap_row:
|
| 126 |
+
tenant = db.get(Tenant, ap_row.tenant_id)
|
| 127 |
+
if not tenant or not tenant.is_active:
|
| 128 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 129 |
+
elif phone:
|
| 130 |
+
# 4. Try phone match
|
| 131 |
+
tenant = db.scalars(select(Tenant).where(Tenant.phone == phone)).first()
|
| 132 |
+
if not tenant or not tenant.is_active:
|
| 133 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 134 |
+
|
| 135 |
+
# Check for collision: tenant already linked to a different auth method
|
| 136 |
+
has_google = tenant.google_id is not None
|
| 137 |
+
existing_ap = db.scalars(
|
| 138 |
+
select(TenantAuthProvider).where(TenantAuthProvider.tenant_id == tenant.id)
|
| 139 |
+
).first()
|
| 140 |
+
if has_google or existing_ap:
|
| 141 |
+
existing_provider = existing_ap.provider if existing_ap else "google"
|
| 142 |
+
raise HTTPException(
|
| 143 |
+
status_code=409,
|
| 144 |
+
detail={"collision": True, "existing_provider": existing_provider},
|
| 145 |
+
)
|
| 146 |
+
# First Telegram login: auto-link
|
| 147 |
+
ap_row = None # will be created below
|
| 148 |
+
else:
|
| 149 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 150 |
+
|
| 151 |
+
# 5. Create auth_providers row if this is a first-time phone-matched login
|
| 152 |
+
if ap_row is None:
|
| 153 |
+
db.add(TenantAuthProvider(
|
| 154 |
+
tenant_id=tenant.id,
|
| 155 |
+
provider="telegram",
|
| 156 |
+
provider_user_id=telegram_user_id,
|
| 157 |
+
))
|
| 158 |
+
|
| 159 |
+
# 6. Store phone on tenant if provided and not yet set
|
| 160 |
+
if phone and not tenant.phone:
|
| 161 |
+
tenant.phone = phone
|
| 162 |
+
|
| 163 |
+
db.commit()
|
| 164 |
+
|
| 165 |
+
# 7. Issue full JWT via cookie (set cookie on the returned JSONResponse, not the injected Response)
|
| 166 |
+
result = JSONResponse(content={"ok": True})
|
| 167 |
+
_set_access_cookie(result, tenant.id)
|
| 168 |
+
return result
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@router.post("/google", response_model=GoogleAuthResponse)
|
| 172 |
+
async def google_auth(body: GoogleAuthRequest, response: Response, db: Session = Depends(get_db)) -> GoogleAuthResponse:
|
| 173 |
+
try:
|
| 174 |
+
google_payload = verify_google_token(body.id_token, config.google_client_id)
|
| 175 |
+
except ValueError:
|
| 176 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 177 |
+
|
| 178 |
+
email = google_payload["email"]
|
| 179 |
+
google_id = google_payload["google_id"]
|
| 180 |
+
|
| 181 |
+
# Pre-check for collision: detect Telegram-only account before binding google_id
|
| 182 |
+
pre_tenant = db.scalars(select(Tenant).where(Tenant.owner_email == email)).first()
|
| 183 |
+
if pre_tenant and pre_tenant.google_id is None:
|
| 184 |
+
existing_ap = db.scalars(
|
| 185 |
+
select(TenantAuthProvider).where(
|
| 186 |
+
TenantAuthProvider.tenant_id == pre_tenant.id,
|
| 187 |
+
TenantAuthProvider.provider != "google",
|
| 188 |
+
)
|
| 189 |
+
).first()
|
| 190 |
+
if existing_ap:
|
| 191 |
+
raise HTTPException(
|
| 192 |
+
status_code=409,
|
| 193 |
+
detail={"collision": True, "existing_provider": existing_ap.provider},
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
tenant = lookup_and_bind_tenant(email=email, google_id=google_id, db=db)
|
| 197 |
+
|
| 198 |
+
# Register Google in auth_providers on first successful login
|
| 199 |
+
has_google_ap = db.scalars(
|
| 200 |
+
select(TenantAuthProvider).where(
|
| 201 |
+
TenantAuthProvider.tenant_id == tenant.id,
|
| 202 |
+
TenantAuthProvider.provider == "google",
|
| 203 |
+
)
|
| 204 |
+
).first()
|
| 205 |
+
if not has_google_ap:
|
| 206 |
+
db.add(TenantAuthProvider(
|
| 207 |
+
tenant_id=tenant.id,
|
| 208 |
+
provider="google",
|
| 209 |
+
provider_user_id=google_id,
|
| 210 |
+
))
|
| 211 |
+
db.commit()
|
| 212 |
+
|
| 213 |
+
if not tenant.totp_enabled:
|
| 214 |
+
_set_access_cookie(response, tenant.id)
|
| 215 |
+
return GoogleAuthResponse(totp_enabled=False)
|
| 216 |
+
|
| 217 |
+
partial_token = issue_partial_jwt(tenant.id, config.jwt_secret_key)
|
| 218 |
+
return GoogleAuthResponse(totp_enabled=True, partial_token=partial_token)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
@router.post("/link/telegram")
|
| 222 |
+
async def link_telegram(
|
| 223 |
+
body: TelegramAuthRequest,
|
| 224 |
+
tenant_id: uuid.UUID = Depends(require_admin_jwt),
|
| 225 |
+
db: Session = Depends(get_db),
|
| 226 |
+
) -> JSONResponse:
|
| 227 |
+
if not config.telegram_bot_token:
|
| 228 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 229 |
+
|
| 230 |
+
data = body.model_dump(exclude_none=True)
|
| 231 |
+
if not _verify_telegram_hmac(data, config.telegram_bot_token):
|
| 232 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 233 |
+
|
| 234 |
+
if time.time() - body.auth_date > 86400:
|
| 235 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 236 |
+
|
| 237 |
+
telegram_user_id = str(body.id)
|
| 238 |
+
|
| 239 |
+
# Already linked to this tenant
|
| 240 |
+
if db.scalars(
|
| 241 |
+
select(TenantAuthProvider).where(
|
| 242 |
+
TenantAuthProvider.tenant_id == tenant_id,
|
| 243 |
+
TenantAuthProvider.provider == "telegram",
|
| 244 |
+
)
|
| 245 |
+
).first():
|
| 246 |
+
raise HTTPException(status_code=409, detail={"already_linked": True})
|
| 247 |
+
|
| 248 |
+
# Telegram ID already used by another tenant
|
| 249 |
+
if db.scalars(
|
| 250 |
+
select(TenantAuthProvider).where(
|
| 251 |
+
TenantAuthProvider.provider == "telegram",
|
| 252 |
+
TenantAuthProvider.provider_user_id == telegram_user_id,
|
| 253 |
+
TenantAuthProvider.tenant_id != tenant_id,
|
| 254 |
+
)
|
| 255 |
+
).first():
|
| 256 |
+
raise HTTPException(status_code=409, detail={"already_linked": True, "used_by_other": True})
|
| 257 |
+
|
| 258 |
+
db.add(TenantAuthProvider(
|
| 259 |
+
tenant_id=tenant_id,
|
| 260 |
+
provider="telegram",
|
| 261 |
+
provider_user_id=telegram_user_id,
|
| 262 |
+
))
|
| 263 |
+
|
| 264 |
+
tenant = db.get(Tenant, tenant_id)
|
| 265 |
+
if body.phone_number and tenant and not tenant.phone:
|
| 266 |
+
tenant.phone = body.phone_number
|
| 267 |
+
|
| 268 |
+
db.commit()
|
| 269 |
+
return JSONResponse(content={"linked": True})
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
@router.post("/link/google")
|
| 273 |
+
async def link_google(
|
| 274 |
+
body: GoogleAuthRequest,
|
| 275 |
+
tenant_id: uuid.UUID = Depends(require_admin_jwt),
|
| 276 |
+
db: Session = Depends(get_db),
|
| 277 |
+
) -> JSONResponse:
|
| 278 |
+
try:
|
| 279 |
+
google_payload = verify_google_token(body.id_token, config.google_client_id)
|
| 280 |
+
except ValueError:
|
| 281 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 282 |
+
|
| 283 |
+
google_id = google_payload["google_id"]
|
| 284 |
+
email = google_payload["email"]
|
| 285 |
+
|
| 286 |
+
# Already linked to this tenant
|
| 287 |
+
if db.scalars(
|
| 288 |
+
select(TenantAuthProvider).where(
|
| 289 |
+
TenantAuthProvider.tenant_id == tenant_id,
|
| 290 |
+
TenantAuthProvider.provider == "google",
|
| 291 |
+
)
|
| 292 |
+
).first():
|
| 293 |
+
raise HTTPException(status_code=409, detail={"already_linked": True})
|
| 294 |
+
|
| 295 |
+
# Google ID already used by another tenant
|
| 296 |
+
if db.scalars(
|
| 297 |
+
select(TenantAuthProvider).where(
|
| 298 |
+
TenantAuthProvider.provider == "google",
|
| 299 |
+
TenantAuthProvider.provider_user_id == google_id,
|
| 300 |
+
TenantAuthProvider.tenant_id != tenant_id,
|
| 301 |
+
)
|
| 302 |
+
).first():
|
| 303 |
+
raise HTTPException(status_code=409, detail={"already_linked": True, "used_by_other": True})
|
| 304 |
+
|
| 305 |
+
db.add(TenantAuthProvider(
|
| 306 |
+
tenant_id=tenant_id,
|
| 307 |
+
provider="google",
|
| 308 |
+
provider_user_id=google_id,
|
| 309 |
+
))
|
| 310 |
+
|
| 311 |
+
tenant = db.get(Tenant, tenant_id)
|
| 312 |
+
if tenant:
|
| 313 |
+
if not tenant.owner_email:
|
| 314 |
+
tenant.owner_email = email
|
| 315 |
+
if not tenant.google_id:
|
| 316 |
+
tenant.google_id = google_id
|
| 317 |
+
|
| 318 |
+
db.commit()
|
| 319 |
+
return JSONResponse(content={"linked": True})
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
@router.post("/totp/setup", response_model=TOTPSetupResponse)
|
| 323 |
+
@router.post("/totp/enroll", response_model=TOTPSetupResponse)
|
| 324 |
+
async def totp_setup(request: Request, db: Session = Depends(get_db)) -> TOTPSetupResponse:
|
| 325 |
+
auth_header = request.headers.get("Authorization", "")
|
| 326 |
+
if not auth_header.startswith("Bearer "):
|
| 327 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 328 |
+
|
| 329 |
+
token = auth_header.removeprefix("Bearer ")
|
| 330 |
+
|
| 331 |
+
try:
|
| 332 |
+
tenant_id_str = decode_partial_jwt(token, config.jwt_secret_key)
|
| 333 |
+
tenant_id = uuid.UUID(tenant_id_str)
|
| 334 |
+
except (ValueError, AttributeError):
|
| 335 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 336 |
+
|
| 337 |
+
tenant = db.get(Tenant, tenant_id)
|
| 338 |
+
if not tenant:
|
| 339 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 340 |
+
|
| 341 |
+
if tenant.totp_enabled:
|
| 342 |
+
raise HTTPException(status_code=403, detail="TOTP already configured")
|
| 343 |
+
|
| 344 |
+
raw_secret = generate_totp_secret()
|
| 345 |
+
plain_codes = generate_backup_codes()
|
| 346 |
+
|
| 347 |
+
tenant.totp_secret = encrypt_secret(raw_secret, config.fernet_key)
|
| 348 |
+
tenant.backup_codes = encode_backup_codes([hash_backup_code(c) for c in plain_codes])
|
| 349 |
+
tenant.totp_enabled = False
|
| 350 |
+
db.commit()
|
| 351 |
+
|
| 352 |
+
return TOTPSetupResponse(
|
| 353 |
+
provisioning_uri=make_provisioning_uri(raw_secret, tenant.owner_email or ""),
|
| 354 |
+
backup_codes=plain_codes,
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
@router.post("/totp/verify")
|
| 359 |
+
async def totp_verify(body: TOTPVerifyRequest, request: Request, db: Session = Depends(get_db)) -> JSONResponse:
|
| 360 |
+
auth_header = request.headers.get("Authorization", "")
|
| 361 |
+
if not auth_header.startswith("Bearer "):
|
| 362 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 363 |
+
|
| 364 |
+
token = auth_header.removeprefix("Bearer ")
|
| 365 |
+
|
| 366 |
+
try:
|
| 367 |
+
tenant_id_str = decode_partial_jwt(token, config.jwt_secret_key)
|
| 368 |
+
tenant_id = uuid.UUID(tenant_id_str)
|
| 369 |
+
except (ValueError, AttributeError):
|
| 370 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 371 |
+
|
| 372 |
+
tenant = db.get(Tenant, tenant_id)
|
| 373 |
+
if not tenant or not tenant.totp_secret:
|
| 374 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 375 |
+
|
| 376 |
+
code = body.code.strip().lower()
|
| 377 |
+
verified = False
|
| 378 |
+
|
| 379 |
+
if _TOTP_RE.match(code):
|
| 380 |
+
verified = verify_totp_code(tenant.totp_secret, code, config.fernet_key)
|
| 381 |
+
elif _BACKUP_RE.match(code) and tenant.backup_codes:
|
| 382 |
+
updated = consume_backup_code(tenant.backup_codes, code)
|
| 383 |
+
if updated is not None:
|
| 384 |
+
tenant.backup_codes = encode_backup_codes(updated)
|
| 385 |
+
verified = True
|
| 386 |
+
|
| 387 |
+
if not verified:
|
| 388 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 389 |
+
|
| 390 |
+
tenant.totp_enabled = True
|
| 391 |
+
db.commit()
|
| 392 |
+
|
| 393 |
+
access_token = issue_full_jwt(tenant.id, config.jwt_secret_key)
|
| 394 |
+
response = JSONResponse(content={"ok": True})
|
| 395 |
+
# SameSite=Strict provides CSRF protection without a CSRF token
|
| 396 |
+
response.set_cookie(
|
| 397 |
+
key="access_token",
|
| 398 |
+
value=access_token,
|
| 399 |
+
httponly=True,
|
| 400 |
+
secure=config.env.name == "production",
|
| 401 |
+
samesite="strict",
|
| 402 |
+
path="/api",
|
| 403 |
+
max_age=86400,
|
| 404 |
+
)
|
| 405 |
+
return response
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
@router.delete("/totp")
|
| 409 |
+
async def totp_disable(
|
| 410 |
+
body: TOTPVerifyRequest,
|
| 411 |
+
tenant_id: uuid.UUID = Depends(require_admin_jwt),
|
| 412 |
+
db: Session = Depends(get_db),
|
| 413 |
+
) -> JSONResponse:
|
| 414 |
+
tenant = db.get(Tenant, tenant_id)
|
| 415 |
+
if not tenant:
|
| 416 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 417 |
+
|
| 418 |
+
if not tenant.totp_enabled or not tenant.totp_secret:
|
| 419 |
+
raise HTTPException(status_code=400, detail="TOTP not configured")
|
| 420 |
+
|
| 421 |
+
code = body.code.strip().lower()
|
| 422 |
+
verified = False
|
| 423 |
+
|
| 424 |
+
if _TOTP_RE.match(code):
|
| 425 |
+
verified = verify_totp_code(tenant.totp_secret, code, config.fernet_key)
|
| 426 |
+
elif _BACKUP_RE.match(code) and tenant.backup_codes:
|
| 427 |
+
updated = consume_backup_code(tenant.backup_codes, code)
|
| 428 |
+
if updated is not None:
|
| 429 |
+
tenant.backup_codes = encode_backup_codes(updated)
|
| 430 |
+
verified = True
|
| 431 |
+
|
| 432 |
+
if not verified:
|
| 433 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 434 |
+
|
| 435 |
+
tenant.totp_enabled = False
|
| 436 |
+
tenant.totp_secret = None
|
| 437 |
+
tenant.backup_codes = None
|
| 438 |
+
db.commit()
|
| 439 |
+
|
| 440 |
+
return JSONResponse(content={"totp_enabled": False})
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
@router.post("/logout")
|
| 444 |
+
async def logout(response: Response) -> dict:
|
| 445 |
+
response.delete_cookie(key="access_token", path="/api")
|
| 446 |
+
return {"ok": True}
|
app/routers/admin_me.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy import select
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from app.models.database import get_db, Tenant
|
| 5 |
+
from app.dependencies.admin_auth import require_admin_jwt
|
| 6 |
+
from app.schemas.admin import AdminMeResponse
|
| 7 |
+
import uuid
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/admin", tags=["admin"])
|
| 10 |
+
|
| 11 |
+
@router.get("/me", response_model=AdminMeResponse)
|
| 12 |
+
def get_admin_me(
|
| 13 |
+
tenant_id: uuid.UUID = Depends(require_admin_jwt),
|
| 14 |
+
db: Session = Depends(get_db)
|
| 15 |
+
):
|
| 16 |
+
"""
|
| 17 |
+
Returns the authenticated tenant's profile.
|
| 18 |
+
Used by the frontend to restore session state.
|
| 19 |
+
"""
|
| 20 |
+
tenant = db.scalar(select(Tenant).where(Tenant.id == tenant_id))
|
| 21 |
+
if not tenant:
|
| 22 |
+
raise HTTPException(status_code=404, detail="Tenant not found")
|
| 23 |
+
return tenant
|
app/routers/admin_settings.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 3 |
+
from sqlalchemy import select
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from app.models.database import get_db, Tenant
|
| 6 |
+
from app.dependencies.admin_auth import require_admin_jwt
|
| 7 |
+
from app.schemas.admin_settings import GoLiveRequest, TenantSettingsUpdate, TenantResponse
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/admin", tags=["admin"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.patch("/go-live", response_model=TenantResponse)
|
| 13 |
+
def patch_admin_go_live(
|
| 14 |
+
request: GoLiveRequest,
|
| 15 |
+
tenant_id: uuid.UUID = Depends(require_admin_jwt),
|
| 16 |
+
db: Session = Depends(get_db)
|
| 17 |
+
):
|
| 18 |
+
tenant = db.scalar(select(Tenant).where(Tenant.id == tenant_id))
|
| 19 |
+
if not tenant:
|
| 20 |
+
raise HTTPException(status_code=404, detail="Tenant not found")
|
| 21 |
+
|
| 22 |
+
tenant.is_active = request.is_active
|
| 23 |
+
db.commit()
|
| 24 |
+
db.refresh(tenant)
|
| 25 |
+
return tenant
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.put("/settings", response_model=TenantResponse)
|
| 29 |
+
def put_admin_settings(
|
| 30 |
+
request: TenantSettingsUpdate,
|
| 31 |
+
tenant_id: uuid.UUID = Depends(require_admin_jwt),
|
| 32 |
+
db: Session = Depends(get_db)
|
| 33 |
+
):
|
| 34 |
+
tenant = db.scalar(select(Tenant).where(Tenant.id == tenant_id))
|
| 35 |
+
if not tenant:
|
| 36 |
+
raise HTTPException(status_code=404, detail="Tenant not found")
|
| 37 |
+
|
| 38 |
+
if request.name is not None:
|
| 39 |
+
tenant.name = request.name
|
| 40 |
+
if request.website_url is not None:
|
| 41 |
+
tenant.website_url = str(request.website_url)
|
| 42 |
+
|
| 43 |
+
if tenant.customization is None:
|
| 44 |
+
tenant.customization = {}
|
| 45 |
+
|
| 46 |
+
customization_update = {}
|
| 47 |
+
if request.industry is not None:
|
| 48 |
+
customization_update["industry"] = request.industry
|
| 49 |
+
if request.timezone is not None:
|
| 50 |
+
customization_update["timezone"] = request.timezone
|
| 51 |
+
if request.chatbot_name is not None:
|
| 52 |
+
customization_update["chatbot_name"] = request.chatbot_name
|
| 53 |
+
if request.chatbot_greeting is not None:
|
| 54 |
+
customization_update["chatbot_greeting"] = request.chatbot_greeting
|
| 55 |
+
|
| 56 |
+
if customization_update:
|
| 57 |
+
# SQLAlchemy requires a new dict object for JSON column mutation tracking
|
| 58 |
+
new_customization = dict(tenant.customization)
|
| 59 |
+
new_customization.update(customization_update)
|
| 60 |
+
tenant.customization = new_customization
|
| 61 |
+
|
| 62 |
+
if "backup_email" in request.model_fields_set:
|
| 63 |
+
tenant.backup_email = str(request.backup_email) if request.backup_email else None
|
| 64 |
+
|
| 65 |
+
db.commit()
|
| 66 |
+
db.refresh(tenant)
|
| 67 |
+
return tenant
|
| 68 |
+
|
app/routers/billing.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy import select
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from app.models.database import get_db, Tenant, CreditEvent
|
| 5 |
+
from app.dependencies.admin_auth import require_admin_jwt
|
| 6 |
+
from app.schemas.billing import BillingSummaryResponse
|
| 7 |
+
import uuid
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/billing", tags=["billing"])
|
| 10 |
+
|
| 11 |
+
@router.get("/summary", response_model=BillingSummaryResponse)
|
| 12 |
+
def get_billing_summary(
|
| 13 |
+
tenant_id: uuid.UUID = Depends(require_admin_jwt),
|
| 14 |
+
db: Session = Depends(get_db)
|
| 15 |
+
):
|
| 16 |
+
"""
|
| 17 |
+
Returns current balance and credit event history for the billing page.
|
| 18 |
+
"""
|
| 19 |
+
tenant = db.scalar(select(Tenant).where(Tenant.id == tenant_id))
|
| 20 |
+
if not tenant:
|
| 21 |
+
raise HTTPException(status_code=404, detail="Tenant not found")
|
| 22 |
+
|
| 23 |
+
events = db.scalars(
|
| 24 |
+
select(CreditEvent)
|
| 25 |
+
.where(CreditEvent.tenant_id == tenant_id)
|
| 26 |
+
.order_by(CreditEvent.created_at.desc())
|
| 27 |
+
.limit(50)
|
| 28 |
+
).all()
|
| 29 |
+
|
| 30 |
+
return BillingSummaryResponse(
|
| 31 |
+
balance_usd=tenant.balance_usd,
|
| 32 |
+
events=list(events)
|
| 33 |
+
)
|
app/routers/conversations.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Literal
|
| 2 |
+
import uuid
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, Query
|
| 5 |
+
from sqlalchemy import select
|
| 6 |
+
from sqlalchemy.exc import SQLAlchemyError
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from app.dependencies.admin_auth import require_admin_jwt
|
| 10 |
+
from app.models.database import Conversation, Message, get_db
|
| 11 |
+
from app.schemas.conversations import (
|
| 12 |
+
ConversationSummary,
|
| 13 |
+
FeedbackRequest,
|
| 14 |
+
FeedbackResponse,
|
| 15 |
+
MessageItem,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
router = APIRouter(tags=["conversations"])
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.get("/conversations", status_code=200, response_model=list[ConversationSummary])
|
| 23 |
+
def list_conversation(
|
| 24 |
+
request: Request,
|
| 25 |
+
page: int = Query(default=1, ge=1),
|
| 26 |
+
page_size: int = Query(default=50, ge=1, le=100),
|
| 27 |
+
db: Session = Depends(get_db),
|
| 28 |
+
_: uuid.UUID = Depends(require_admin_jwt),
|
| 29 |
+
) -> list[ConversationSummary]:
|
| 30 |
+
|
| 31 |
+
tenant_id = request.state.tenant_id
|
| 32 |
+
|
| 33 |
+
conversations = db.scalars(
|
| 34 |
+
select(Conversation)
|
| 35 |
+
.where(Conversation.tenant_id == tenant_id)
|
| 36 |
+
.order_by(Conversation.last_message_at.desc())
|
| 37 |
+
.limit(page_size)
|
| 38 |
+
.offset((page - 1) * page_size)
|
| 39 |
+
).all()
|
| 40 |
+
|
| 41 |
+
result: list[ConversationSummary] = []
|
| 42 |
+
for conversation in conversations:
|
| 43 |
+
result.append(
|
| 44 |
+
ConversationSummary(
|
| 45 |
+
id=conversation.id,
|
| 46 |
+
started_at=conversation.started_at,
|
| 47 |
+
last_message_at=conversation.last_message_at,
|
| 48 |
+
message_count=(
|
| 49 |
+
conversation.message_count if conversation.message_count else 0
|
| 50 |
+
),
|
| 51 |
+
last_message=conversation.last_message_preview,
|
| 52 |
+
)
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
return result
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.get(
|
| 59 |
+
"/conversations/{conversation_id}/messages",
|
| 60 |
+
status_code=200,
|
| 61 |
+
response_model=list[MessageItem],
|
| 62 |
+
)
|
| 63 |
+
def get_conversation_messages(
|
| 64 |
+
request: Request,
|
| 65 |
+
conversation_id: uuid.UUID,
|
| 66 |
+
page: int = Query(default=1, ge=1),
|
| 67 |
+
page_size: int = Query(default=100, ge=1, le=200),
|
| 68 |
+
db: Session = Depends(get_db),
|
| 69 |
+
_: uuid.UUID = Depends(require_admin_jwt),
|
| 70 |
+
) -> list[MessageItem]:
|
| 71 |
+
|
| 72 |
+
result: list[MessageItem] = []
|
| 73 |
+
tenant_id = request.state.tenant_id
|
| 74 |
+
|
| 75 |
+
conv = db.get(Conversation, conversation_id)
|
| 76 |
+
|
| 77 |
+
if conv is None or conv.tenant_id != tenant_id:
|
| 78 |
+
raise HTTPException(status_code=404, detail="Conversation doesn't exist")
|
| 79 |
+
|
| 80 |
+
messages = db.scalars(
|
| 81 |
+
select(Message)
|
| 82 |
+
.where(
|
| 83 |
+
Message.conversation_id == conversation_id,
|
| 84 |
+
)
|
| 85 |
+
.order_by(Message.timestamp.asc())
|
| 86 |
+
.limit(page_size)
|
| 87 |
+
.offset((page - 1) * page_size)
|
| 88 |
+
).all()
|
| 89 |
+
|
| 90 |
+
for msg in messages:
|
| 91 |
+
result.append(
|
| 92 |
+
MessageItem(
|
| 93 |
+
role="user",
|
| 94 |
+
text=msg.user_input,
|
| 95 |
+
timestamp=msg.timestamp,
|
| 96 |
+
intent=msg.intent,
|
| 97 |
+
)
|
| 98 |
+
)
|
| 99 |
+
result.append(
|
| 100 |
+
MessageItem(
|
| 101 |
+
role="assistant",
|
| 102 |
+
text=msg.bot_response,
|
| 103 |
+
timestamp=msg.timestamp,
|
| 104 |
+
intent=msg.intent,
|
| 105 |
+
)
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
return result
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _apply_feedback(
|
| 112 |
+
message_id: uuid.UUID,
|
| 113 |
+
score: Literal[-1, 1],
|
| 114 |
+
field: Literal["user_feedback", "tenant_feedback"],
|
| 115 |
+
tenant_id: uuid.UUID,
|
| 116 |
+
db: Session,
|
| 117 |
+
) -> FeedbackResponse:
|
| 118 |
+
|
| 119 |
+
message = db.get(Message, message_id)
|
| 120 |
+
|
| 121 |
+
if not message:
|
| 122 |
+
raise HTTPException(status_code=404, detail="Message not found")
|
| 123 |
+
|
| 124 |
+
# Verify the message's conversation belongs to tenant_id
|
| 125 |
+
conv = db.get(Conversation, message.conversation_id)
|
| 126 |
+
|
| 127 |
+
if not conv or conv.tenant_id != tenant_id:
|
| 128 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
setattr(message, field, score)
|
| 132 |
+
db.commit()
|
| 133 |
+
db.refresh(message)
|
| 134 |
+
except SQLAlchemyError as e:
|
| 135 |
+
db.rollback()
|
| 136 |
+
raise HTTPException(status_code=500, detail="Database error") from e
|
| 137 |
+
|
| 138 |
+
return FeedbackResponse(
|
| 139 |
+
message_id=message.id, score=score
|
| 140 |
+
) # use body.score because it's already validated as Literal[-1, 1]; if message.user_feedback, the system flags it as an error because its return type is `int | None`
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@router.patch(
|
| 144 |
+
"/messages/{message_id}/feedback/user",
|
| 145 |
+
status_code=200,
|
| 146 |
+
response_model=FeedbackResponse,
|
| 147 |
+
)
|
| 148 |
+
async def submit_user_feedback(
|
| 149 |
+
message_id: uuid.UUID,
|
| 150 |
+
body: FeedbackRequest,
|
| 151 |
+
request: Request,
|
| 152 |
+
db: Session = Depends(get_db),
|
| 153 |
+
) -> FeedbackResponse:
|
| 154 |
+
|
| 155 |
+
tenant_id = request.state.tenant_id
|
| 156 |
+
|
| 157 |
+
feedback_response = _apply_feedback(
|
| 158 |
+
message_id=message_id,
|
| 159 |
+
score=body.score,
|
| 160 |
+
field="user_feedback",
|
| 161 |
+
tenant_id=tenant_id,
|
| 162 |
+
db=db,
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
return feedback_response
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@router.patch(
|
| 169 |
+
"/messages/{message_id}/feedback/tenant",
|
| 170 |
+
status_code=200,
|
| 171 |
+
response_model=FeedbackResponse,
|
| 172 |
+
)
|
| 173 |
+
async def submit_tenant_feedback(
|
| 174 |
+
message_id: uuid.UUID,
|
| 175 |
+
body: FeedbackRequest,
|
| 176 |
+
request: Request,
|
| 177 |
+
db: Session = Depends(get_db),
|
| 178 |
+
) -> FeedbackResponse:
|
| 179 |
+
|
| 180 |
+
tenant_id = request.state.tenant_id
|
| 181 |
+
|
| 182 |
+
feedback_response = _apply_feedback(
|
| 183 |
+
message_id=message_id,
|
| 184 |
+
score=body.score,
|
| 185 |
+
field="tenant_feedback",
|
| 186 |
+
tenant_id=tenant_id,
|
| 187 |
+
db=db,
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
return feedback_response
|
app/routers/knowledge_base.py
CHANGED
|
@@ -1,31 +1,502 @@
|
|
|
|
|
| 1 |
import uuid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 4 |
from sqlalchemy import select
|
| 5 |
from sqlalchemy.orm import Session
|
| 6 |
|
| 7 |
-
from app.
|
| 8 |
-
from app.
|
| 9 |
-
from app.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
@router.post("/kb/entries", status_code=201, response_model=KBEntryResponse)
|
| 15 |
def create_entry(
|
| 16 |
-
request: Request,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
) -> KBEntryResponse:
|
| 18 |
|
| 19 |
knowledge_base = KnowledgeBase()
|
| 20 |
knowledge_base.tenant_id = (
|
| 21 |
request.state.tenant_id
|
| 22 |
) # get the tenant_id from the middleware
|
| 23 |
-
knowledge_base.
|
|
|
|
|
|
|
|
|
|
| 24 |
db.add(knowledge_base)
|
| 25 |
db.commit()
|
| 26 |
db.refresh(knowledge_base)
|
| 27 |
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
@router.get("/kb/entries/{entry_id}", status_code=200, response_model=KBEntryResponse)
|
|
@@ -43,17 +514,23 @@ def read_entry(
|
|
| 43 |
if kb is None:
|
| 44 |
raise HTTPException(status_code=404, detail="Entry not found")
|
| 45 |
|
| 46 |
-
return kb
|
| 47 |
|
| 48 |
|
| 49 |
@router.get("/kb/entries", status_code=200, response_model=list[KBEntryResponse])
|
| 50 |
def read_entries(
|
| 51 |
-
request: Request,
|
|
|
|
|
|
|
|
|
|
| 52 |
) -> list[KBEntryResponse]:
|
| 53 |
|
| 54 |
tenant_id = request.state.tenant_id
|
| 55 |
kb_entries = db.scalars(
|
| 56 |
-
select(KnowledgeBase)
|
|
|
|
|
|
|
|
|
|
| 57 |
).all()
|
| 58 |
|
| 59 |
return kb_entries # type: ignore[truthy-bool]
|
|
@@ -64,7 +541,10 @@ def update_entry(
|
|
| 64 |
request: Request,
|
| 65 |
entry_id: uuid.UUID,
|
| 66 |
entry: KBEntryUpdate,
|
|
|
|
| 67 |
db: Session = Depends(get_db),
|
|
|
|
|
|
|
| 68 |
) -> KBEntryResponse:
|
| 69 |
|
| 70 |
tenant_id = request.state.tenant_id
|
|
@@ -76,16 +556,43 @@ def update_entry(
|
|
| 76 |
|
| 77 |
if kb is None:
|
| 78 |
raise HTTPException(status_code=404, detail="Entry not found")
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
db.commit()
|
| 81 |
db.refresh(kb)
|
| 82 |
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
|
| 86 |
@router.delete("/kb/entries/{entry_id}", status_code=204)
|
| 87 |
def delete_entry(
|
| 88 |
-
request: Request,
|
|
|
|
|
|
|
|
|
|
| 89 |
) -> None:
|
| 90 |
|
| 91 |
tenant_id = request.state.tenant_id
|
|
@@ -100,3 +607,67 @@ def delete_entry(
|
|
| 100 |
|
| 101 |
db.delete(kb)
|
| 102 |
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import urllib.parse
|
| 2 |
import uuid
|
| 3 |
+
import csv
|
| 4 |
+
import io
|
| 5 |
+
import json
|
| 6 |
+
import time
|
| 7 |
+
from collections import defaultdict
|
| 8 |
+
from datetime import datetime, timezone
|
| 9 |
|
| 10 |
+
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, UploadFile, File
|
| 11 |
from sqlalchemy import select
|
| 12 |
from sqlalchemy.orm import Session
|
| 13 |
|
| 14 |
+
from app.dependencies.admin_auth import require_admin_jwt
|
| 15 |
+
from app.models.smart_models import KnowledgeBase, SyncJob
|
| 16 |
+
from app.schemas.knowledge_base import (
|
| 17 |
+
KBEntryCreate, KBEntryResponse, KBEntryUpdate,
|
| 18 |
+
BulkUploadResponse, BulkUploadError,
|
| 19 |
+
WPSyncRequest, WPSyncResponse, WPSyncPreviewResponse,
|
| 20 |
+
WPSyncPreviewEntry, WPSyncConfirmResponse, WPSyncStatusResponse,
|
| 21 |
+
WPSingleSyncRequest, WPSingleSyncResponse,
|
| 22 |
+
KBReindexResponse, WPSyncJobControlResponse, WPSyncResumeResponse,
|
| 23 |
+
)
|
| 24 |
+
from app.models.database import SessionLocal, get_db
|
| 25 |
+
from app.services.dependencies import get_embedding_service, get_vector_store
|
| 26 |
+
from app.services.embedding_service import EmbeddingService
|
| 27 |
+
from app.services.kb_chunking import embed_entry_for_retrieval
|
| 28 |
+
from app.services.vector_store import VectorStore
|
| 29 |
+
from app.services.wordpress_sync import (
|
| 30 |
+
WordPressSyncError,
|
| 31 |
+
WordPressPostTypeError,
|
| 32 |
+
fetch_post_type_rest_base,
|
| 33 |
+
fetch_single_wordpress_post,
|
| 34 |
+
fetch_single_wordpress_post_by_url,
|
| 35 |
+
preview_wordpress_entries,
|
| 36 |
+
validate_public_https_url,
|
| 37 |
+
)
|
| 38 |
+
import redis as redis_lib
|
| 39 |
+
from app.utils.sync_control import set_cancel_flag, set_pause_flag, clear_pause_flag
|
| 40 |
+
from app.workers.kb_reindex import reindex_kb_for_tenant
|
| 41 |
+
from app.workers.wordpress_sync import sync_wordpress_site
|
| 42 |
|
| 43 |
+
router = APIRouter(dependencies=[Depends(require_admin_jwt)])
|
| 44 |
+
|
| 45 |
+
_SYNC_RATE_LIMIT: dict[str, list[float]] = defaultdict(list)
|
| 46 |
+
_SYNC_RATE_LIMIT_WINDOW_SECONDS = 60
|
| 47 |
+
_SYNC_RATE_LIMIT_MAX_REQUESTS = 10
|
| 48 |
+
|
| 49 |
+
_TITLE_SYNONYMS = frozenset({"title", "headline", "name", "subject", "heading"})
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _extract_csv_fields(row: dict) -> tuple[str, str, str | None]:
|
| 53 |
+
"""Extract (title, content, source_url) from a CSV row using smart column detection.
|
| 54 |
+
|
| 55 |
+
- Title: first column whose lowercased name is in _TITLE_SYNONYMS; value used as title.
|
| 56 |
+
- source_url: column whose lowercased name is exactly "source_url".
|
| 57 |
+
- Content: all remaining columns' non-empty values joined with newline.
|
| 58 |
+
"""
|
| 59 |
+
title_col = next((k for k in row if k.lower().strip() in _TITLE_SYNONYMS), None)
|
| 60 |
+
title_val = (row[title_col] or "").strip() if title_col else ""
|
| 61 |
+
title = title_val if title_val else "Untitled"
|
| 62 |
+
|
| 63 |
+
source_url_col = next((k for k in row if k.lower().strip() == "source_url"), None)
|
| 64 |
+
source_url_val = (row[source_url_col] or "").strip() if source_url_col else ""
|
| 65 |
+
source_url = source_url_val if source_url_val else None
|
| 66 |
+
|
| 67 |
+
skip = {title_col, source_url_col} - {None}
|
| 68 |
+
parts = [v.strip() for k, v in row.items() if k not in skip and v and v.strip()]
|
| 69 |
+
content = "\n".join(parts)
|
| 70 |
+
|
| 71 |
+
return title, content, source_url
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _embed_entry(
|
| 75 |
+
embedding_service: EmbeddingService,
|
| 76 |
+
vector_store: VectorStore,
|
| 77 |
+
tenant_id: str,
|
| 78 |
+
entry_id: str,
|
| 79 |
+
text: str,
|
| 80 |
+
metadata: dict[str, str | int | float | bool | None] | None = None,
|
| 81 |
+
) -> None:
|
| 82 |
+
embed_entry_for_retrieval(
|
| 83 |
+
embedding_service=embedding_service,
|
| 84 |
+
vector_store=vector_store,
|
| 85 |
+
tenant_id=tenant_id,
|
| 86 |
+
entry_id=entry_id,
|
| 87 |
+
text=text,
|
| 88 |
+
metadata=metadata,
|
| 89 |
+
)
|
| 90 |
+
with SessionLocal() as db:
|
| 91 |
+
kb = db.get(KnowledgeBase, uuid.UUID(entry_id))
|
| 92 |
+
if kb is not None:
|
| 93 |
+
kb.is_indexed = True
|
| 94 |
+
db.commit()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _enforce_sync_rate_limit(tenant_id: uuid.UUID) -> None:
|
| 98 |
+
now = time.time()
|
| 99 |
+
key = str(tenant_id)
|
| 100 |
+
recent = [
|
| 101 |
+
timestamp
|
| 102 |
+
for timestamp in _SYNC_RATE_LIMIT[key]
|
| 103 |
+
if now - timestamp < _SYNC_RATE_LIMIT_WINDOW_SECONDS
|
| 104 |
+
]
|
| 105 |
+
if len(recent) >= _SYNC_RATE_LIMIT_MAX_REQUESTS:
|
| 106 |
+
raise HTTPException(status_code=429, detail="WordPress sync rate limit exceeded")
|
| 107 |
+
recent.append(now)
|
| 108 |
+
_SYNC_RATE_LIMIT[key] = recent
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _http_error_from_sync_error(exc: WordPressSyncError) -> HTTPException:
|
| 112 |
+
return HTTPException(status_code=400, detail=str(exc))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@router.post("/kb/sync/wordpress/preview", status_code=200, response_model=WPSyncPreviewResponse)
|
| 116 |
+
async def preview_wordpress_posts(
|
| 117 |
+
request: Request,
|
| 118 |
+
payload: WPSyncRequest,
|
| 119 |
+
) -> WPSyncPreviewResponse:
|
| 120 |
+
tenant_id = request.state.tenant_id
|
| 121 |
+
_enforce_sync_rate_limit(tenant_id)
|
| 122 |
+
try:
|
| 123 |
+
site_url, entries, errors = await preview_wordpress_entries(payload.site_url, post_type=payload.post_type)
|
| 124 |
+
except WordPressPostTypeError as exc:
|
| 125 |
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 126 |
+
except WordPressSyncError as exc:
|
| 127 |
+
raise _http_error_from_sync_error(exc) from exc
|
| 128 |
+
|
| 129 |
+
return WPSyncPreviewResponse(
|
| 130 |
+
site_url=site_url,
|
| 131 |
+
entries=[
|
| 132 |
+
WPSyncPreviewEntry(
|
| 133 |
+
title=entry.title,
|
| 134 |
+
content=entry.content,
|
| 135 |
+
source_url=entry.source_url,
|
| 136 |
+
warnings=entry.warnings,
|
| 137 |
+
)
|
| 138 |
+
for entry in entries
|
| 139 |
+
],
|
| 140 |
+
errors=errors,
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@router.post("/kb/sync/wordpress/confirm", status_code=202, response_model=WPSyncConfirmResponse)
|
| 145 |
+
async def confirm_wordpress_sync(
|
| 146 |
+
request: Request,
|
| 147 |
+
payload: WPSyncRequest,
|
| 148 |
+
db: Session = Depends(get_db),
|
| 149 |
+
) -> WPSyncConfirmResponse:
|
| 150 |
+
tenant_id = request.state.tenant_id
|
| 151 |
+
_enforce_sync_rate_limit(tenant_id)
|
| 152 |
+
try:
|
| 153 |
+
site_url = validate_public_https_url(payload.site_url)
|
| 154 |
+
except WordPressSyncError as exc:
|
| 155 |
+
raise _http_error_from_sync_error(exc) from exc
|
| 156 |
+
|
| 157 |
+
try:
|
| 158 |
+
rest_base = await fetch_post_type_rest_base(site_url, payload.post_type)
|
| 159 |
+
except WordPressPostTypeError as exc:
|
| 160 |
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 161 |
+
except WordPressSyncError as exc:
|
| 162 |
+
raise _http_error_from_sync_error(exc) from exc
|
| 163 |
+
|
| 164 |
+
existing_job = db.scalars(
|
| 165 |
+
select(SyncJob).where(
|
| 166 |
+
SyncJob.tenant_id == tenant_id,
|
| 167 |
+
SyncJob.source_url == site_url,
|
| 168 |
+
SyncJob.status.in_(["queued", "running"]),
|
| 169 |
+
)
|
| 170 |
+
).first()
|
| 171 |
+
if existing_job is not None:
|
| 172 |
+
return WPSyncConfirmResponse(job_id=existing_job.id)
|
| 173 |
+
|
| 174 |
+
job = SyncJob(tenant_id=tenant_id, source_url=site_url, status="queued")
|
| 175 |
+
db.add(job)
|
| 176 |
+
db.commit()
|
| 177 |
+
db.refresh(job)
|
| 178 |
+
try:
|
| 179 |
+
sync_wordpress_site.apply_async(args=[str(tenant_id), str(job.id), site_url, rest_base, payload.force])
|
| 180 |
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
| 181 |
+
job.status = "failed"
|
| 182 |
+
job.error_message = f"Task enqueue failed: {type(exc).__name__}: {exc}"
|
| 183 |
+
job.updated_at = datetime.now(timezone.utc)
|
| 184 |
+
db.commit()
|
| 185 |
+
raise HTTPException(status_code=503, detail="Failed to enqueue WordPress sync job") from exc
|
| 186 |
+
return WPSyncConfirmResponse(job_id=job.id)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@router.get("/kb/sync/status/{job_id}", status_code=200, response_model=WPSyncStatusResponse)
|
| 190 |
+
def read_wordpress_sync_status(
|
| 191 |
+
request: Request,
|
| 192 |
+
job_id: uuid.UUID,
|
| 193 |
+
db: Session = Depends(get_db),
|
| 194 |
+
) -> WPSyncStatusResponse:
|
| 195 |
+
tenant_id = request.state.tenant_id
|
| 196 |
+
job = db.scalars(
|
| 197 |
+
select(SyncJob).where(SyncJob.id == job_id, SyncJob.tenant_id == tenant_id)
|
| 198 |
+
).first()
|
| 199 |
+
if job is None:
|
| 200 |
+
raise HTTPException(status_code=404, detail="Sync job not found")
|
| 201 |
+
return WPSyncStatusResponse(
|
| 202 |
+
job_id=job.id,
|
| 203 |
+
status=job.status,
|
| 204 |
+
source_url=job.source_url,
|
| 205 |
+
total=job.total,
|
| 206 |
+
processed=job.processed,
|
| 207 |
+
failed=job.failed,
|
| 208 |
+
error_message=job.error_message,
|
| 209 |
+
created=job.created,
|
| 210 |
+
updated=job.updated,
|
| 211 |
+
skipped=job.skipped,
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
_CANCELLABLE_STATUSES = {"queued", "running"}
|
| 216 |
+
_TERMINAL_STATUSES = {"completed", "failed", "cancelled", "expired"}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@router.post("/kb/sync/wordpress/{job_id}/cancel", status_code=200, response_model=WPSyncJobControlResponse)
|
| 220 |
+
def cancel_wordpress_sync(
|
| 221 |
+
request: Request,
|
| 222 |
+
job_id: uuid.UUID,
|
| 223 |
+
db: Session = Depends(get_db),
|
| 224 |
+
) -> WPSyncJobControlResponse:
|
| 225 |
+
tenant_id = request.state.tenant_id
|
| 226 |
+
job = db.scalars(
|
| 227 |
+
select(SyncJob).where(SyncJob.id == job_id, SyncJob.tenant_id == tenant_id)
|
| 228 |
+
).first()
|
| 229 |
+
if job is None:
|
| 230 |
+
raise HTTPException(status_code=404, detail="Sync job not found")
|
| 231 |
+
if job.status in _TERMINAL_STATUSES:
|
| 232 |
+
raise HTTPException(status_code=409, detail=f"Cannot cancel a job with status '{job.status}'")
|
| 233 |
+
try:
|
| 234 |
+
set_cancel_flag(str(job_id))
|
| 235 |
+
except redis_lib.RedisError as exc:
|
| 236 |
+
raise HTTPException(status_code=503, detail="Job control unavailable — Redis unreachable") from exc
|
| 237 |
+
job.status = "cancelled"
|
| 238 |
+
job.updated_at = datetime.now(timezone.utc)
|
| 239 |
+
db.commit()
|
| 240 |
+
return WPSyncJobControlResponse(job_id=job.id, status=job.status)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
@router.post("/kb/sync/wordpress/{job_id}/pause", status_code=202, response_model=WPSyncJobControlResponse)
|
| 244 |
+
def pause_wordpress_sync(
|
| 245 |
+
request: Request,
|
| 246 |
+
job_id: uuid.UUID,
|
| 247 |
+
db: Session = Depends(get_db),
|
| 248 |
+
) -> WPSyncJobControlResponse:
|
| 249 |
+
tenant_id = request.state.tenant_id
|
| 250 |
+
job = db.scalars(
|
| 251 |
+
select(SyncJob).where(SyncJob.id == job_id, SyncJob.tenant_id == tenant_id)
|
| 252 |
+
).first()
|
| 253 |
+
if job is None:
|
| 254 |
+
raise HTTPException(status_code=404, detail="Sync job not found")
|
| 255 |
+
if job.status != "running":
|
| 256 |
+
raise HTTPException(status_code=409, detail=f"Cannot pause a job with status '{job.status}'")
|
| 257 |
+
try:
|
| 258 |
+
set_pause_flag(str(job_id))
|
| 259 |
+
except redis_lib.RedisError as exc:
|
| 260 |
+
raise HTTPException(status_code=503, detail="Job control unavailable — Redis unreachable") from exc
|
| 261 |
+
return WPSyncJobControlResponse(job_id=job.id, status="pausing")
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
@router.post("/kb/sync/wordpress/{job_id}/resume", status_code=202, response_model=WPSyncResumeResponse)
|
| 265 |
+
def resume_wordpress_sync(
|
| 266 |
+
request: Request,
|
| 267 |
+
job_id: uuid.UUID,
|
| 268 |
+
db: Session = Depends(get_db),
|
| 269 |
+
) -> WPSyncResumeResponse:
|
| 270 |
+
tenant_id = request.state.tenant_id
|
| 271 |
+
original_job = db.scalars(
|
| 272 |
+
select(SyncJob).where(SyncJob.id == job_id, SyncJob.tenant_id == tenant_id)
|
| 273 |
+
).first()
|
| 274 |
+
if original_job is None:
|
| 275 |
+
raise HTTPException(status_code=404, detail="Sync job not found")
|
| 276 |
+
if original_job.status != "paused":
|
| 277 |
+
raise HTTPException(status_code=409, detail=f"Cannot resume a job with status '{original_job.status}'")
|
| 278 |
+
|
| 279 |
+
# Clear any stale pause flag (non-fatal if Redis is unavailable)
|
| 280 |
+
try:
|
| 281 |
+
clear_pause_flag(str(job_id))
|
| 282 |
+
except redis_lib.RedisError:
|
| 283 |
+
pass # Stale flag will expire on its own; proceed with resume
|
| 284 |
+
|
| 285 |
+
new_job = SyncJob(
|
| 286 |
+
tenant_id=tenant_id,
|
| 287 |
+
source_url=original_job.source_url,
|
| 288 |
+
rest_base=original_job.rest_base,
|
| 289 |
+
status="queued",
|
| 290 |
+
)
|
| 291 |
+
db.add(new_job)
|
| 292 |
+
db.flush()
|
| 293 |
+
try:
|
| 294 |
+
sync_wordpress_site.apply_async(
|
| 295 |
+
args=[str(tenant_id), str(new_job.id), original_job.source_url, original_job.rest_base, False]
|
| 296 |
+
)
|
| 297 |
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
| 298 |
+
new_job.status = "failed"
|
| 299 |
+
db.commit()
|
| 300 |
+
raise HTTPException(status_code=503, detail="Failed to enqueue resume job") from exc
|
| 301 |
+
db.commit()
|
| 302 |
+
return WPSyncResumeResponse(original_job_id=original_job.id, new_job_id=new_job.id)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
@router.post("/kb/sync/wordpress/single", status_code=200, response_model=WPSingleSyncResponse)
|
| 306 |
+
async def sync_single_wordpress_post(
|
| 307 |
+
request: Request,
|
| 308 |
+
payload: WPSingleSyncRequest,
|
| 309 |
+
db: Session = Depends(get_db),
|
| 310 |
+
embedding_service: EmbeddingService = Depends(get_embedding_service),
|
| 311 |
+
vector_store: VectorStore = Depends(get_vector_store),
|
| 312 |
+
) -> WPSingleSyncResponse:
|
| 313 |
+
tenant_id = request.state.tenant_id
|
| 314 |
+
_enforce_sync_rate_limit(tenant_id)
|
| 315 |
+
|
| 316 |
+
try:
|
| 317 |
+
site_url = validate_public_https_url(payload.site_url)
|
| 318 |
+
validate_public_https_url(payload.source_url)
|
| 319 |
+
except WordPressSyncError as exc:
|
| 320 |
+
raise _http_error_from_sync_error(exc) from exc
|
| 321 |
+
|
| 322 |
+
if urllib.parse.urlparse(payload.source_url).netloc != urllib.parse.urlparse(site_url).netloc:
|
| 323 |
+
raise HTTPException(status_code=400, detail="source_url must be on the same domain as site_url")
|
| 324 |
+
|
| 325 |
+
try:
|
| 326 |
+
rest_base = await fetch_post_type_rest_base(site_url, payload.post_type)
|
| 327 |
+
except WordPressPostTypeError as exc:
|
| 328 |
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 329 |
+
except WordPressSyncError as exc:
|
| 330 |
+
raise _http_error_from_sync_error(exc) from exc
|
| 331 |
+
|
| 332 |
+
try:
|
| 333 |
+
wp_entry = await fetch_single_wordpress_post(site_url, payload.source_url, rest_base)
|
| 334 |
+
except WordPressSyncError as exc:
|
| 335 |
+
raise _http_error_from_sync_error(exc) from exc
|
| 336 |
+
|
| 337 |
+
kb = db.scalars(
|
| 338 |
+
select(KnowledgeBase).where(
|
| 339 |
+
KnowledgeBase.tenant_id == tenant_id,
|
| 340 |
+
KnowledgeBase.source_url == wp_entry.source_url,
|
| 341 |
+
)
|
| 342 |
+
).first()
|
| 343 |
+
|
| 344 |
+
if kb is None:
|
| 345 |
+
kb = KnowledgeBase(
|
| 346 |
+
tenant_id=tenant_id,
|
| 347 |
+
title=wp_entry.title,
|
| 348 |
+
content=wp_entry.content,
|
| 349 |
+
source_url=wp_entry.source_url,
|
| 350 |
+
origin="synced",
|
| 351 |
+
)
|
| 352 |
+
db.add(kb)
|
| 353 |
+
db.commit()
|
| 354 |
+
db.refresh(kb)
|
| 355 |
+
_embed_entry(
|
| 356 |
+
embedding_service,
|
| 357 |
+
vector_store,
|
| 358 |
+
str(tenant_id),
|
| 359 |
+
str(kb.id),
|
| 360 |
+
kb.content,
|
| 361 |
+
metadata={"origin": kb.origin, "source_url": kb.source_url},
|
| 362 |
+
)
|
| 363 |
+
action = "created"
|
| 364 |
+
elif payload.force:
|
| 365 |
+
kb.title = wp_entry.title
|
| 366 |
+
kb.content = wp_entry.content
|
| 367 |
+
kb.is_indexed = False
|
| 368 |
+
kb.updated_at = datetime.now(timezone.utc)
|
| 369 |
+
db.commit()
|
| 370 |
+
db.refresh(kb)
|
| 371 |
+
_embed_entry(
|
| 372 |
+
embedding_service,
|
| 373 |
+
vector_store,
|
| 374 |
+
str(tenant_id),
|
| 375 |
+
str(kb.id),
|
| 376 |
+
kb.content,
|
| 377 |
+
metadata={"origin": kb.origin, "source_url": kb.source_url},
|
| 378 |
+
)
|
| 379 |
+
action = "updated"
|
| 380 |
+
else:
|
| 381 |
+
action = "skipped"
|
| 382 |
+
|
| 383 |
+
return WPSingleSyncResponse(action=action, entry=KBEntryResponse.model_validate(kb))
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
@router.post("/kb/bulk-upload", status_code=201, response_model=BulkUploadResponse)
|
| 387 |
+
def bulk_upload_entries(
|
| 388 |
+
request: Request,
|
| 389 |
+
background_tasks: BackgroundTasks,
|
| 390 |
+
file: UploadFile = File(...),
|
| 391 |
+
db: Session = Depends(get_db),
|
| 392 |
+
embedding_service: EmbeddingService = Depends(get_embedding_service),
|
| 393 |
+
vector_store: VectorStore = Depends(get_vector_store),
|
| 394 |
+
) -> BulkUploadResponse:
|
| 395 |
+
tenant_id = request.state.tenant_id
|
| 396 |
+
created_count = 0
|
| 397 |
+
errors = []
|
| 398 |
+
|
| 399 |
+
parsed_data = []
|
| 400 |
+
filename = file.filename or ""
|
| 401 |
+
|
| 402 |
+
content_type = file.content_type or ""
|
| 403 |
+
is_csv = filename.endswith(".csv") or content_type == "text/csv"
|
| 404 |
+
is_json = filename.endswith(".json") or content_type == "application/json"
|
| 405 |
+
|
| 406 |
+
if not is_csv and not is_json:
|
| 407 |
+
raise HTTPException(status_code=400, detail="Unsupported file format. Use CSV or JSON.")
|
| 408 |
+
|
| 409 |
+
try:
|
| 410 |
+
content = file.file.read()
|
| 411 |
+
if is_csv:
|
| 412 |
+
parsed_data = list(csv.DictReader(io.StringIO(content.decode("utf-8"))))
|
| 413 |
+
else:
|
| 414 |
+
parsed_data = json.loads(content.decode("utf-8"))
|
| 415 |
+
if not isinstance(parsed_data, list):
|
| 416 |
+
raise ValueError("JSON must be an array of objects")
|
| 417 |
+
except (UnicodeDecodeError, ValueError) as e:
|
| 418 |
+
raise HTTPException(status_code=400, detail=f"Failed to parse file: {str(e)}")
|
| 419 |
+
|
| 420 |
+
valid_entries = []
|
| 421 |
+
|
| 422 |
+
for idx, row in enumerate(parsed_data):
|
| 423 |
+
row_num = idx + 1
|
| 424 |
+
if not isinstance(row, dict):
|
| 425 |
+
errors.append(BulkUploadError(row=row_num, reason="Row is not an object/dictionary"))
|
| 426 |
+
continue
|
| 427 |
+
|
| 428 |
+
if is_csv:
|
| 429 |
+
row_title, row_content, row_source_url = _extract_csv_fields(row)
|
| 430 |
+
else:
|
| 431 |
+
row_content = row.get("content")
|
| 432 |
+
row_title = row.get("title") or "Untitled"
|
| 433 |
+
row_source_url = row.get("source_url") or None
|
| 434 |
+
|
| 435 |
+
if not row_content:
|
| 436 |
+
errors.append(BulkUploadError(row=row_num, reason="Missing 'content' field"))
|
| 437 |
+
continue
|
| 438 |
+
|
| 439 |
+
kb = KnowledgeBase(
|
| 440 |
+
tenant_id=tenant_id,
|
| 441 |
+
content=row_content,
|
| 442 |
+
title=row_title,
|
| 443 |
+
source_url=row_source_url,
|
| 444 |
+
origin="manual",
|
| 445 |
+
)
|
| 446 |
+
valid_entries.append(kb)
|
| 447 |
+
|
| 448 |
+
if valid_entries:
|
| 449 |
+
db.add_all(valid_entries)
|
| 450 |
+
db.commit()
|
| 451 |
+
for kb in valid_entries:
|
| 452 |
+
db.refresh(kb)
|
| 453 |
+
background_tasks.add_task(
|
| 454 |
+
_embed_entry,
|
| 455 |
+
embedding_service,
|
| 456 |
+
vector_store,
|
| 457 |
+
str(kb.tenant_id),
|
| 458 |
+
str(kb.id),
|
| 459 |
+
kb.content,
|
| 460 |
+
{"origin": kb.origin, "source_url": kb.source_url},
|
| 461 |
+
)
|
| 462 |
+
created_count += 1
|
| 463 |
+
|
| 464 |
+
return BulkUploadResponse(created=created_count, errors=errors)
|
| 465 |
|
| 466 |
|
| 467 |
@router.post("/kb/entries", status_code=201, response_model=KBEntryResponse)
|
| 468 |
def create_entry(
|
| 469 |
+
request: Request,
|
| 470 |
+
entry: KBEntryCreate,
|
| 471 |
+
background_tasks: BackgroundTasks,
|
| 472 |
+
db: Session = Depends(get_db),
|
| 473 |
+
embedding_service: EmbeddingService = Depends(get_embedding_service),
|
| 474 |
+
vector_store: VectorStore = Depends(get_vector_store),
|
| 475 |
) -> KBEntryResponse:
|
| 476 |
|
| 477 |
knowledge_base = KnowledgeBase()
|
| 478 |
knowledge_base.tenant_id = (
|
| 479 |
request.state.tenant_id
|
| 480 |
) # get the tenant_id from the middleware
|
| 481 |
+
knowledge_base.title = entry.title
|
| 482 |
+
knowledge_base.content = entry.content
|
| 483 |
+
knowledge_base.source_url = entry.source_url
|
| 484 |
+
knowledge_base.origin = "manual"
|
| 485 |
db.add(knowledge_base)
|
| 486 |
db.commit()
|
| 487 |
db.refresh(knowledge_base)
|
| 488 |
|
| 489 |
+
background_tasks.add_task(
|
| 490 |
+
_embed_entry,
|
| 491 |
+
embedding_service,
|
| 492 |
+
vector_store,
|
| 493 |
+
str(knowledge_base.tenant_id),
|
| 494 |
+
str(knowledge_base.id),
|
| 495 |
+
knowledge_base.content,
|
| 496 |
+
{"origin": knowledge_base.origin, "source_url": knowledge_base.source_url},
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
return KBEntryResponse.model_validate(knowledge_base)
|
| 500 |
|
| 501 |
|
| 502 |
@router.get("/kb/entries/{entry_id}", status_code=200, response_model=KBEntryResponse)
|
|
|
|
| 514 |
if kb is None:
|
| 515 |
raise HTTPException(status_code=404, detail="Entry not found")
|
| 516 |
|
| 517 |
+
return KBEntryResponse.model_validate(kb)
|
| 518 |
|
| 519 |
|
| 520 |
@router.get("/kb/entries", status_code=200, response_model=list[KBEntryResponse])
|
| 521 |
def read_entries(
|
| 522 |
+
request: Request,
|
| 523 |
+
page: int = Query(default=1, ge=1),
|
| 524 |
+
page_size: int = Query(default=50, ge=1, le=100),
|
| 525 |
+
db: Session = Depends(get_db),
|
| 526 |
) -> list[KBEntryResponse]:
|
| 527 |
|
| 528 |
tenant_id = request.state.tenant_id
|
| 529 |
kb_entries = db.scalars(
|
| 530 |
+
select(KnowledgeBase)
|
| 531 |
+
.where(KnowledgeBase.tenant_id == tenant_id)
|
| 532 |
+
.limit(page_size)
|
| 533 |
+
.offset((page - 1) * page_size)
|
| 534 |
).all()
|
| 535 |
|
| 536 |
return kb_entries # type: ignore[truthy-bool]
|
|
|
|
| 541 |
request: Request,
|
| 542 |
entry_id: uuid.UUID,
|
| 543 |
entry: KBEntryUpdate,
|
| 544 |
+
background_tasks: BackgroundTasks,
|
| 545 |
db: Session = Depends(get_db),
|
| 546 |
+
embedding_service: EmbeddingService = Depends(get_embedding_service),
|
| 547 |
+
vector_store: VectorStore = Depends(get_vector_store),
|
| 548 |
) -> KBEntryResponse:
|
| 549 |
|
| 550 |
tenant_id = request.state.tenant_id
|
|
|
|
| 556 |
|
| 557 |
if kb is None:
|
| 558 |
raise HTTPException(status_code=404, detail="Entry not found")
|
| 559 |
+
|
| 560 |
+
if kb.origin == "synced":
|
| 561 |
+
raise HTTPException(
|
| 562 |
+
status_code=409,
|
| 563 |
+
detail="Synced entries are read-only through the normal KB update endpoint",
|
| 564 |
+
)
|
| 565 |
+
|
| 566 |
+
if entry.title is not None:
|
| 567 |
+
kb.title = entry.title
|
| 568 |
+
if entry.content is not None:
|
| 569 |
+
kb.content = entry.content
|
| 570 |
+
if "source_url" in entry.model_fields_set:
|
| 571 |
+
kb.source_url = entry.source_url
|
| 572 |
+
kb.is_indexed = False
|
| 573 |
+
|
| 574 |
db.commit()
|
| 575 |
db.refresh(kb)
|
| 576 |
|
| 577 |
+
background_tasks.add_task(
|
| 578 |
+
_embed_entry,
|
| 579 |
+
embedding_service,
|
| 580 |
+
vector_store,
|
| 581 |
+
str(kb.tenant_id),
|
| 582 |
+
str(kb.id),
|
| 583 |
+
kb.content,
|
| 584 |
+
{"origin": kb.origin, "source_url": kb.source_url},
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
return KBEntryResponse.model_validate(kb)
|
| 588 |
|
| 589 |
|
| 590 |
@router.delete("/kb/entries/{entry_id}", status_code=204)
|
| 591 |
def delete_entry(
|
| 592 |
+
request: Request,
|
| 593 |
+
entry_id: uuid.UUID,
|
| 594 |
+
db: Session = Depends(get_db),
|
| 595 |
+
vector_store: VectorStore = Depends(get_vector_store),
|
| 596 |
) -> None:
|
| 597 |
|
| 598 |
tenant_id = request.state.tenant_id
|
|
|
|
| 607 |
|
| 608 |
db.delete(kb)
|
| 609 |
db.commit()
|
| 610 |
+
|
| 611 |
+
vector_store.delete_docs(tenant_id=str(tenant_id), entry_ids=[str(entry_id)])
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
@router.post("/kb/entries/{entry_id}/resync", status_code=200, response_model=KBEntryResponse)
|
| 615 |
+
async def resync_entry(
|
| 616 |
+
request: Request,
|
| 617 |
+
entry_id: uuid.UUID,
|
| 618 |
+
background_tasks: BackgroundTasks,
|
| 619 |
+
db: Session = Depends(get_db),
|
| 620 |
+
embedding_service: EmbeddingService = Depends(get_embedding_service),
|
| 621 |
+
vector_store: VectorStore = Depends(get_vector_store),
|
| 622 |
+
) -> KBEntryResponse:
|
| 623 |
+
tenant_id = request.state.tenant_id
|
| 624 |
+
|
| 625 |
+
kb = db.scalars(
|
| 626 |
+
select(KnowledgeBase).where(
|
| 627 |
+
KnowledgeBase.id == entry_id, KnowledgeBase.tenant_id == tenant_id
|
| 628 |
+
)
|
| 629 |
+
).first()
|
| 630 |
+
if kb is None:
|
| 631 |
+
raise HTTPException(status_code=404, detail="Entry not found")
|
| 632 |
+
|
| 633 |
+
if kb.origin != "synced":
|
| 634 |
+
raise HTTPException(status_code=400, detail="Only synced entries can be resynced")
|
| 635 |
+
|
| 636 |
+
if kb.source_url is None:
|
| 637 |
+
raise HTTPException(status_code=400, detail="Entry has no source URL to resync from")
|
| 638 |
+
|
| 639 |
+
try:
|
| 640 |
+
validate_public_https_url(kb.source_url)
|
| 641 |
+
except WordPressSyncError as exc:
|
| 642 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 643 |
+
|
| 644 |
+
try:
|
| 645 |
+
wp_entry = await fetch_single_wordpress_post_by_url(kb.source_url)
|
| 646 |
+
except WordPressSyncError as exc:
|
| 647 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 648 |
+
|
| 649 |
+
kb.title = wp_entry.title
|
| 650 |
+
kb.content = wp_entry.content
|
| 651 |
+
kb.is_indexed = False
|
| 652 |
+
kb.updated_at = datetime.now(timezone.utc)
|
| 653 |
+
db.commit()
|
| 654 |
+
db.refresh(kb)
|
| 655 |
+
|
| 656 |
+
background_tasks.add_task(
|
| 657 |
+
_embed_entry,
|
| 658 |
+
embedding_service,
|
| 659 |
+
vector_store,
|
| 660 |
+
str(kb.tenant_id),
|
| 661 |
+
str(kb.id),
|
| 662 |
+
kb.content,
|
| 663 |
+
{"origin": kb.origin, "source_url": kb.source_url},
|
| 664 |
+
)
|
| 665 |
+
|
| 666 |
+
return KBEntryResponse.model_validate(kb)
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
@router.post("/kb/reindex", status_code=202, response_model=KBReindexResponse)
|
| 670 |
+
def trigger_reindex(request: Request) -> KBReindexResponse:
|
| 671 |
+
tenant_id = request.state.tenant_id
|
| 672 |
+
task = reindex_kb_for_tenant.delay(str(tenant_id))
|
| 673 |
+
return KBReindexResponse(task_id=task.id)
|
app/routers/super_admin_auth.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Response
|
| 2 |
+
from fastapi.responses import JSONResponse
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
from app.dependencies.super_admin_auth import require_super_admin_jwt
|
| 6 |
+
from app.services.google_auth import (
|
| 7 |
+
SUPER_ADMIN_TOKEN_EXPIRE_HOURS,
|
| 8 |
+
issue_super_admin_jwt,
|
| 9 |
+
verify_google_token,
|
| 10 |
+
)
|
| 11 |
+
from app.utils.config import get_config
|
| 12 |
+
|
| 13 |
+
config = get_config()
|
| 14 |
+
router = APIRouter(prefix="/super-admin", tags=["super-admin-auth"])
|
| 15 |
+
|
| 16 |
+
_TOKEN_MAX_AGE = SUPER_ADMIN_TOKEN_EXPIRE_HOURS * 3600
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class SuperAdminGoogleAuthRequest(BaseModel):
|
| 20 |
+
id_token: str
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.post("/auth/google")
|
| 24 |
+
async def super_admin_google_auth(body: SuperAdminGoogleAuthRequest) -> JSONResponse:
|
| 25 |
+
try:
|
| 26 |
+
google_payload = verify_google_token(body.id_token, config.google_client_id)
|
| 27 |
+
except ValueError:
|
| 28 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 29 |
+
|
| 30 |
+
email = google_payload.get("email")
|
| 31 |
+
if not email:
|
| 32 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 33 |
+
if email.lower() not in [e.lower() for e in config.super_admin_emails]:
|
| 34 |
+
raise HTTPException(status_code=403, detail="Forbidden")
|
| 35 |
+
|
| 36 |
+
token = issue_super_admin_jwt(email, config.jwt_secret_key)
|
| 37 |
+
response = JSONResponse(content={"ok": True})
|
| 38 |
+
response.set_cookie(
|
| 39 |
+
key="super_admin_token",
|
| 40 |
+
value=token,
|
| 41 |
+
httponly=True,
|
| 42 |
+
secure=config.env.name == "production",
|
| 43 |
+
samesite="lax",
|
| 44 |
+
path="/api",
|
| 45 |
+
max_age=_TOKEN_MAX_AGE,
|
| 46 |
+
)
|
| 47 |
+
return response
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@router.post("/auth/logout")
|
| 51 |
+
async def super_admin_logout(response: Response) -> dict:
|
| 52 |
+
response.delete_cookie(key="super_admin_token", path="/api")
|
| 53 |
+
return {"ok": True}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.get("/me")
|
| 57 |
+
async def super_admin_me(email: str = Depends(require_super_admin_jwt)) -> dict:
|
| 58 |
+
return {"email": email}
|
app/routers/super_admin_rates.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/routers/super_admin_rates.py
|
| 2 |
+
"""Super-admin endpoints for managing LLM model rates.
|
| 3 |
+
|
| 4 |
+
GET /super-admin/llm-rates — returns active rates and full history
|
| 5 |
+
POST /super-admin/llm-rates — adds a new rate, closes the previous active rate
|
| 6 |
+
"""
|
| 7 |
+
from datetime import date
|
| 8 |
+
from decimal import Decimal
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 12 |
+
from pydantic import BaseModel
|
| 13 |
+
from sqlalchemy import select
|
| 14 |
+
from sqlalchemy.orm import Session
|
| 15 |
+
|
| 16 |
+
from app.dependencies.super_admin_auth import require_super_admin_jwt
|
| 17 |
+
from app.models.database import LLMModel, get_db
|
| 18 |
+
|
| 19 |
+
router = APIRouter(prefix="/super-admin", tags=["super-admin-rates"])
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# Pydantic schemas
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class LLMRateOut(BaseModel):
|
| 28 |
+
"""Serialises one row of llm_models using only columns that exist in the DB."""
|
| 29 |
+
|
| 30 |
+
id: int
|
| 31 |
+
provider: str
|
| 32 |
+
model_name: str
|
| 33 |
+
usd_per_1k_input: Decimal
|
| 34 |
+
usd_per_1k_output: Decimal
|
| 35 |
+
effective_from: date
|
| 36 |
+
effective_to: Optional[date]
|
| 37 |
+
|
| 38 |
+
model_config = {"from_attributes": True}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class LLMRatesResponse(BaseModel):
|
| 42 |
+
active: list[LLMRateOut]
|
| 43 |
+
history: list[LLMRateOut]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class LLMRateCreate(BaseModel):
|
| 47 |
+
provider: str
|
| 48 |
+
model_name: str
|
| 49 |
+
usd_per_1k_input: Decimal
|
| 50 |
+
usd_per_1k_output: Decimal
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
# GET /llm-rates
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.get("/llm-rates", response_model=LLMRatesResponse)
|
| 59 |
+
def list_llm_rates(
|
| 60 |
+
_email: str = Depends(require_super_admin_jwt),
|
| 61 |
+
db: Session = Depends(get_db),
|
| 62 |
+
) -> LLMRatesResponse:
|
| 63 |
+
"""Return all active rates and the full rate history."""
|
| 64 |
+
all_rates = db.execute(
|
| 65 |
+
select(LLMModel).order_by(LLMModel.effective_from.desc())
|
| 66 |
+
).scalars().all()
|
| 67 |
+
|
| 68 |
+
active = [r for r in all_rates if r.effective_to is None]
|
| 69 |
+
history = list(all_rates) # already ordered by effective_from desc
|
| 70 |
+
|
| 71 |
+
return LLMRatesResponse(
|
| 72 |
+
active=[LLMRateOut.model_validate(r) for r in active],
|
| 73 |
+
history=[LLMRateOut.model_validate(r) for r in history],
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ---------------------------------------------------------------------------
|
| 78 |
+
# POST /llm-rates
|
| 79 |
+
# ---------------------------------------------------------------------------
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@router.post("/llm-rates", response_model=LLMRateOut, status_code=201)
|
| 83 |
+
def create_llm_rate(
|
| 84 |
+
body: LLMRateCreate,
|
| 85 |
+
_email: str = Depends(require_super_admin_jwt),
|
| 86 |
+
db: Session = Depends(get_db),
|
| 87 |
+
) -> LLMRateOut:
|
| 88 |
+
"""Add a new rate for a model, closing the current active rate if one exists."""
|
| 89 |
+
today = date.today()
|
| 90 |
+
|
| 91 |
+
# Close the current active rate for this provider + model_name, if any
|
| 92 |
+
existing_active = db.execute(
|
| 93 |
+
select(LLMModel).where(
|
| 94 |
+
LLMModel.provider == body.provider,
|
| 95 |
+
LLMModel.model_name == body.model_name,
|
| 96 |
+
LLMModel.effective_to.is_(None),
|
| 97 |
+
)
|
| 98 |
+
).scalars().first()
|
| 99 |
+
|
| 100 |
+
if existing_active is not None:
|
| 101 |
+
existing_active.effective_to = today
|
| 102 |
+
|
| 103 |
+
# Insert the new rate
|
| 104 |
+
new_rate = LLMModel(
|
| 105 |
+
provider=body.provider,
|
| 106 |
+
model_name=body.model_name,
|
| 107 |
+
usd_per_1k_input=body.usd_per_1k_input,
|
| 108 |
+
usd_per_1k_output=body.usd_per_1k_output,
|
| 109 |
+
effective_from=today,
|
| 110 |
+
effective_to=None,
|
| 111 |
+
)
|
| 112 |
+
db.add(new_rate)
|
| 113 |
+
db.commit()
|
| 114 |
+
db.refresh(new_rate)
|
| 115 |
+
|
| 116 |
+
return LLMRateOut.model_validate(new_rate)
|