GitHub Actions commited on
Commit
55c0d78
·
1 Parent(s): 4413b85

Deploy from GitHub Actions (2026-05-31 09:04 UTC)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .DS_Store +0 -0
  2. .dockerignore +31 -0
  3. .geminiignore +7 -0
  4. .gitignore +13 -5
  5. DOCKER_SETUP.md +69 -2
  6. Dockerfile +22 -45
  7. PROJECT.md +141 -0
  8. alembic/versions/00f16d0affc6_add_auth_providers_table_and_phone_to_.py +46 -0
  9. alembic/versions/05aee5d64d1b_add_context_summary_and_summarization_.py +47 -0
  10. alembic/versions/0704837a1594_add_backup_email_to_tenants.py +32 -0
  11. alembic/versions/126f5b1610a6_add_billing_tables.py +62 -0
  12. alembic/versions/3d1418b32af0_retire_gemini_2_0_flash_add_gemini_2_5_.py +64 -0
  13. alembic/versions/485fbe495e6b_add_feedback_columns_to_messages.py +34 -0
  14. alembic/versions/73ae38d1200c_add_retrieved_doc_ids_to_messages.py +32 -0
  15. alembic/versions/752c239e659a_add_rest_base_to_sync_jobs_expand_.py +40 -0
  16. alembic/versions/7992949ccdf5_add_industries_table.py +56 -0
  17. alembic/versions/7aec975a4140_credits_to_usd_refactor.py +64 -0
  18. alembic/versions/84de4a13cffd_add_ai_verdict_scores_to_messages.py +36 -0
  19. alembic/versions/9ad31e299e42_add_is_indexed_to_knowledge_entries.py +38 -0
  20. alembic/versions/9b95a5ce2ac1_add_gifted_billing_mode_check_constraint.py +30 -0
  21. alembic/versions/9d051350268a_seed_llm_models_initial_rates.py +59 -0
  22. alembic/versions/a53f4c8d9b21_kb_schema_redesign_and_sync_jobs.py +95 -0
  23. alembic/versions/ac67923a3324_add_created_updated_skipped_to_sync_jobs.py +36 -0
  24. alembic/versions/b58e26e702df_add_is_blocked_to_tenants_and_note_.py +36 -0
  25. alembic/versions/bf4016f7dd2a_add_website_url_to_tenants.py +32 -0
  26. alembic/versions/c9e1f8a2b7d4_add_auth_fields_to_tenants.py +39 -0
  27. alembic/versions/d1e126fba5fb_add_title_category_tag_to_kb_entries.py +36 -0
  28. alembic/versions/d2d812e2c226_add_last_message_preview_to_.py +32 -0
  29. alembic/versions/d7470fcd70c6_add_backup_codes_to_tenants.py +32 -0
  30. alembic/versions/e5cc44e2040a_make_tenant_id_non_nullable_on_users_.py +44 -0
  31. alembic/versions/f6988c25696e_add_customization_to_tenants.py +29 -0
  32. app/dependencies/__init__.py +0 -0
  33. app/dependencies/admin_auth.py +23 -0
  34. app/dependencies/super_admin_auth.py +27 -0
  35. app/main.py +216 -28
  36. app/middleware/tenant_auth.py +13 -2
  37. app/models/conversation_manager.py +281 -89
  38. app/models/database.py +324 -42
  39. app/models/information_extractor.py +12 -10
  40. app/models/smart_models.py +174 -49
  41. app/routers/admin_apikey.py +28 -0
  42. app/routers/admin_attention.py +26 -0
  43. app/routers/admin_auth.py +446 -0
  44. app/routers/admin_me.py +23 -0
  45. app/routers/admin_settings.py +68 -0
  46. app/routers/billing.py +33 -0
  47. app/routers/conversations.py +190 -0
  48. app/routers/knowledge_base.py +585 -14
  49. app/routers/super_admin_auth.py +58 -0
  50. 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/development_notes/
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. Stop Development Environment
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:3.11-slim AS base
 
 
 
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
- # Install dependencies using uv
52
- # Using "uv pip install" instead of "uv sync" for system-wide installation
53
- #
54
- # Flags explained:
55
- # pip install = Use pip-compatible interface (supports --system)
56
- # --system = Install to system Python (not venv) - CRITICAL for Docker
57
- # -r pyproject.toml = Install from project dependencies
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 (installed to system with --system flag)
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 because we used --system flag during install
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, installed to system)
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 because we used --system flag during install
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 UserPreference, UserInsight, ConversationTopic
 
 
 
 
24
  from app.data.training_data import TRAINING_DATA
25
- from app.routers import knowledge_base
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(fastapi_app: FastAPI): # pylint: disable=unused-argument
 
 
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
- seed_demo_tenant(session)
 
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
- app.include_router(knowledge_base.router)
 
 
 
 
 
 
 
 
 
 
 
 
 
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": "healthy",
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
- @app.post("/chat", response_model=ChatResponse)
132
- async def chat(request: ChatRequest):
133
- logger.debug("Chat request received: %s ...", request.message[:50])
 
 
 
 
 
 
134
  start_time = time.time()
135
 
 
 
 
 
 
136
  try:
 
 
137
  # Generate session ID if not provided
138
- session_id = request.session_id or str(uuid.uuid4())
139
 
140
  # Detect language
141
- language = nlp_engine.detect_language(request.message)
142
 
143
  # Process message
144
- intent, confidence = nlp_engine.classify_intent(request.message)
145
- entities = nlp_engine.extract_entities(request.message, language)
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, language, request.message, session_id, entities
 
 
 
 
 
 
 
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
- request.message,
179
- response,
180
- intent,
181
- confidence,
182
- entities,
183
- language,
184
- response_time_ms,
 
 
 
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("Error processing chat request: %s", str(e), exc_info=True)
 
 
 
 
 
213
  raise HTTPException(status_code=500, detail="Internal server error") from e
214
 
215
 
216
- @app.get("/analytics/{session_id}")
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
- @app.get("/analytics/{session_id}/stats")
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 Dict, List, Optional
5
- from sqlalchemy import func
 
6
  from sqlalchemy.exc import SQLAlchemyError, OperationalError
7
- from groq import Groq
8
 
9
- # Absolute imports from project root
10
- from app.utils.config import get_logger
11
- from app.models.database import User, Conversation, Message, SessionLocal
 
 
 
 
 
 
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.query(User).filter(User.session_id == session_id).first()
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
- db_session.query(Conversation)
54
- .filter(
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=30):
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 = 5
97
- ) -> List[Dict]:
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
- db_session.query(Message)
105
- .filter(Message.conversation_id == conversation.id)
106
  .order_by(Message.timestamp.desc())
107
  .limit(limit)
108
- .all()
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": msg.timestamp.isoformat(),
 
 
119
  }
120
  )
121
  return context
@@ -137,7 +173,9 @@ class ConversationManager:
137
  )
138
 
139
  thinking_block = thinking_block_match.group(1)
140
- bot_response = bot_response_match.group(1)
 
 
141
  else:
142
  bot_response = content
143
 
@@ -148,32 +186,70 @@ class ConversationManager:
148
  intent: str,
149
  language: str,
150
  user_input: str,
151
- session_id: str = None,
152
- entities: Dict = None,
153
- ) -> str:
 
 
 
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(session_id)
 
 
161
  conversation = self.get_active_conversation(user)
162
 
163
  if conversation:
164
- context = self.get_conversation_context(conversation)
 
 
 
 
 
 
165
 
166
  # Prepare history messages for LLM context
167
- for conv in context:
168
- history_messages.extend(
169
- [
170
- {"role": "user", "content": conv["user_input"]},
171
- {
172
- "role": "assistant",
173
- "content": conv["bot_response"],
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
- # Create LLM Client
198
- client = Groq(api_key=self.config.llm_api_key)
199
 
200
- llm_response = client.chat.completions.create(
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
- thinking_block, response = self.parse_thinking_block(
208
- llm_response.choices[0].message.content
 
 
209
  )
210
 
211
- return thinking_block, response
 
 
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
- confidence: float = None,
220
- entities: Dict = None,
 
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=json.dumps(entities) if entities else None,
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.message_count += 1
 
256
 
257
  # Update user stats
258
- user.total_messages += 1
259
  user.last_seen = datetime.now(timezone.utc)
260
 
261
  db_session.commit()
262
 
263
- self.logger.debug("Conversation saved for session %s", session_id)
 
 
 
 
 
 
 
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
- except Exception as e:
 
 
272
  self.logger.error("Failed to save conversation: %s", str(e), exc_info=True)
273
 
274
- def get_conversation_history(self, session_id: str) -> List[Dict]:
275
  """Get conversation history for a session (for analytics endpoint)"""
276
  try:
277
  with SessionLocal() as db_session:
278
- user = (
279
- db_session.query(User).filter(User.session_id == session_id).first()
280
  )
281
  if not user:
282
  return []
283
 
284
- messages = (
285
- db_session.query(Message)
286
- .join(Conversation)
287
- .filter(Conversation.user_id == user.id)
288
- .order_by(Message.timestamp.desc())
289
- .limit(self.config.nlp["max_history"] if self.config else 50)
290
- .all()
291
- )
292
 
293
  history = []
294
  for msg in reversed(messages): # Reverse for chronological order
295
  history.append(
296
  {
297
- "timestamp": msg.timestamp.isoformat(),
 
 
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": json.loads(msg.entities) if msg.entities else {},
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) -> Dict:
316
  """Get user statistics"""
317
  try:
318
  with SessionLocal() as db_session:
319
- user = (
320
- db_session.query(User).filter(User.session_id == session_id).first()
321
  )
322
  if not user:
323
  return {}
324
 
325
  # Get conversation count
326
- conversation_count = (
327
- db_session.query(
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": user.first_seen.isoformat(),
338
- "last_seen": user.last_seen.isoformat(),
 
 
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 sessionmaker, relationship, declarative_base
19
- from sqlalchemy.dialects.postgresql import UUID
 
 
 
 
 
 
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
- Base = declarative_base(metadata=MetaData(naming_convention=convention))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
52
- session_id = Column(String(255), unique=True, index=True)
53
- preferred_language = Column(String(10), default="en")
54
- first_seen = Column(DateTime, default=lambda: datetime.now(timezone.utc))
55
- last_seen = Column(DateTime, default=lambda: datetime.now(timezone.utc))
56
- total_messages = Column(Integer, default=0)
57
- tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True)
 
 
 
 
 
 
 
 
58
 
59
  # Relationships
60
- conversations = relationship(
 
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 = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
81
- user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
82
- started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
83
- last_message_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
84
- message_count = Column(Integer, default=0)
85
- is_active = Column(Boolean, default=True)
86
- tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
106
- conversation_id = Column(
 
 
107
  UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=False
108
  )
109
 
110
  # Message content
111
- user_input = Column(Text, nullable=False)
112
- bot_response = Column(Text, nullable=False)
113
 
114
  # NLP analysis
115
- detected_language = Column(String(10))
116
- intent = Column(String(100))
117
- confidence = Column(Float)
118
- entities = Column(Text) # JSON string
 
 
 
 
 
119
 
120
  # Metadata
121
- timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc))
122
- response_time_ms = Column(Integer) # How long the bot took to respond
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
  # Relationships
125
- conversation = relationship("Conversation", back_populates="messages")
 
 
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
- # create all tables in the database
133
- Base.metadata.create_all(bind=engine)
134
- logger.info("Database tables created successfully")
 
 
 
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) -> Dict[str, List[str]]:
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
- for category, patterns in self.patterns["product_mentions"].items():
161
- for pattern in patterns:
162
- if re.search(pattern, message, re.IGNORECASE):
163
- category_name = category.split("_", maxsplit=1)[
164
- 0
165
- ] # "product_x_y" -> "product", "x_y" -> "product"
166
- if category_name not in interests:
167
- interests.append(category_name)
 
 
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"] = List(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
- Column,
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 = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
24
- user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
 
 
 
 
25
 
26
  # Preference details
27
- preference_type = Column(
28
  String(50), nullable=False
29
  ) # e.g., "name", "product_interest", "communication_style"
30
- preference_key = Column(
31
  String(100), nullable=False
32
  ) # e.g., "user_name", "preferred_products", "formality_level"
33
- preference_value = Column(
34
  Text, nullable=False
35
  ) # e.g., "John", "toys,gifts", "casual"
36
 
37
  # Learning metadata
38
- confidence_score = Column(
39
  Float, default=1.0
40
  ) # How confident the model is (0.0-1.0)
41
- learned_from_messages = Column(
42
  Integer, default=1
43
  ) # Number of messages that taught us this
44
- first_learned = Column(DateTime, default=lambda: datetime.now(timezone.utc))
45
- last_updated = Column(DateTime, default=lambda: datetime.now(timezone.utc))
 
 
 
 
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 = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
58
- conversation_id = Column(
 
 
59
  UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=False
60
  )
61
 
62
  # Topic details
63
- topic = Column(
64
  String(100), nullable=False
65
  ) # "product_inquiry", "order_management", "support"
66
- subtopic = Column(String(100)) # "toys", "cancellation", "shipping_issue"
67
- keywords = Column(Text) # JSON list of relevant keywords
 
 
 
 
68
 
69
  # Topic metadata
70
- first_mentioned = Column(DateTime, default=lambda: datetime.now(timezone.utc))
71
- message_count = Column(Integer, default=1) # How many messages discussed this topic
72
- importance_score = Column(
 
 
 
 
73
  Float, default=1.0
74
  ) # How important this topic was (0.0-1.0)
75
 
76
  # Relationships
77
- conversation = relationship("Conversation", back_populates="topics")
 
 
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 = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
87
- user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
 
 
 
 
88
 
89
  # Insight details
90
- insight_type = Column(
91
  String(50), nullable=False
92
  ) # "behavior_pattern", "preference_trend", "interaction_style"
93
- insight_key = Column(
94
  String(100), nullable=False
95
  ) # "most_active_time", "preferred_topics", "question_complexity"
96
- insight_value = Column(Text, nullable=False) # "morning", "toys,gifts", "simple"
97
- insight_description = Column(Text) # Human-readable explanation
 
 
 
 
98
 
99
  # Analytics metadata
100
- confidence_level = Column(Float, default=1.0) # Statistical confidence (0.0-1.0)
101
- based_on_messages = Column(Integer, default=0) # Number of messages analyzed
102
- last_calculated = Column(DateTime, default=lambda: datetime.now(timezone.utc))
103
- is_active = Column(Boolean, default=True) # Is this insight still relevant?
 
 
 
 
 
 
 
 
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 = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
116
- created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
117
- updated_at = Column(
 
 
 
 
118
  DateTime,
119
  default=lambda: datetime.now(timezone.utc),
120
  onupdate=lambda: datetime.now(timezone.utc),
121
  )
122
- tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False)
123
- entry = Column(Text, nullable=False)
 
 
 
 
 
 
 
 
 
 
124
 
125
  def __repr__(self):
126
  return f"<KnowledgeBase(id={self.id} tenant_id={self.tenant_id})>"
127
 
128
 
129
- class Tenant(Base):
130
- __tablename__ = "tenants"
131
 
132
- id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
133
- name = Column(String(255), nullable=False)
134
- api_key = Column(String(255), unique=True, nullable=False)
135
- is_active = Column(Boolean, default=True)
136
- created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
137
- updated_at = Column(
 
 
 
138
  DateTime,
139
  default=lambda: datetime.now(timezone.utc),
140
  onupdate=lambda: datetime.now(timezone.utc),
141
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
- # Relationships
144
- users = relationship("User", back_populates="tenant")
145
- conversations = relationship("Conversation", back_populates="tenant")
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
  def __repr__(self):
148
- return f"<Tenant(id={self.id} name={self.name})>"
 
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.models.smart_models import KnowledgeBase
8
- from app.schemas.knowledge_base import KBEntryCreate, KBEntryResponse, KBEntryUpdate
9
- from app.models.database import get_db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
- router = APIRouter()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
 
14
  @router.post("/kb/entries", status_code=201, response_model=KBEntryResponse)
15
  def create_entry(
16
- request: Request, entry: KBEntryCreate, db: Session = Depends(get_db)
 
 
 
 
 
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.entry = entry.entry # type: ignore[truthy-bool]
 
 
 
24
  db.add(knowledge_base)
25
  db.commit()
26
  db.refresh(knowledge_base)
27
 
28
- return knowledge_base
 
 
 
 
 
 
 
 
 
 
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, db: Session = Depends(get_db)
 
 
 
52
  ) -> list[KBEntryResponse]:
53
 
54
  tenant_id = request.state.tenant_id
55
  kb_entries = db.scalars(
56
- select(KnowledgeBase).where(KnowledgeBase.tenant_id == tenant_id)
 
 
 
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
- kb.entry = entry.entry # type: ignore[truthy-bool]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  db.commit()
81
  db.refresh(kb)
82
 
83
- return kb
 
 
 
 
 
 
 
 
 
 
84
 
85
 
86
  @router.delete("/kb/entries/{entry_id}", status_code=204)
87
  def delete_entry(
88
- request: Request, entry_id: uuid.UUID, db: Session = Depends(get_db)
 
 
 
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)