Compare commits
240 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 801743ba11 | |||
| 59fdb614e0 | |||
| b7ad5294a5 | |||
| 49949066d3 | |||
| d87fc4e55c | |||
| 44511a8fd7 | |||
| a7699d3598 | |||
| 1f4a621910 | |||
| 637cfa7940 | |||
| 35e2cc8ff2 | |||
| 80775959db | |||
| 309c7a1d70 | |||
| 8e041538ad | |||
| 3e9d944b83 | |||
| 5dbdf50f39 | |||
| 2506641b32 | |||
| 29b3e1acb9 | |||
| 0fdcdb511c | |||
| 84508b3826 | |||
| 0d59389c40 | |||
| ba86d588b9 | |||
| 40e3ea8876 | |||
| 0d8f26fa2a | |||
| 53695b69ea | |||
| cddc143b05 | |||
| c93ba9d94e | |||
| 5d92ce7b3b | |||
| a10a435662 | |||
| f9048ef073 | |||
| 45bd511831 | |||
| b13ab4975a | |||
| 46a5b2cac4 | |||
| ae46812895 | |||
| 7e3ff9d5c7 | |||
| dfd22916ef | |||
| c50cd58d9e | |||
| 849c21ad59 | |||
| ebf31980cf | |||
| 7df0f5d711 | |||
| a852f2914e | |||
| aaf3142942 | |||
| 1eac7546bc | |||
| fb98e06cec | |||
| daaa88a53d | |||
| 880dd6408c | |||
| dc699bee86 | |||
| e5c8b7beba | |||
| a0477ddac0 | |||
| 51265c29be | |||
| d43cd45aac | |||
| f7f8a302f8 | |||
| 624699bf8d | |||
| 7d80e09226 | |||
| 3be812ea00 | |||
| 85af047bca | |||
| 6189cff376 | |||
| db4701bae7 | |||
| 40cc99af5c | |||
| a0269248b4 | |||
| f6c67a9b6c | |||
| dada44cbe7 | |||
| 3f9132622f | |||
| c2e261dd17 | |||
| b05204db14 | |||
| 57f4f3daca | |||
| c19b08e068 | |||
| 79c3c84681 | |||
| baf4af26b2 | |||
| 05043b123a | |||
| 2e17066031 | |||
| c9d16c51cf | |||
| 3caa08c460 | |||
| 3e3fadac71 | |||
| 4581264935 | |||
| f6d9fc8124 | |||
| 47b74dfa8c | |||
| e3e913f4fb | |||
| 5998127f86 | |||
| b107d25fc2 | |||
| 063e41e995 | |||
| 00edc43e5c | |||
| 1f8c4d5177 | |||
| c48513349e | |||
| d16bd388b1 | |||
| 11b45cffac | |||
| ceb972d771 | |||
| c02fc75421 | |||
| f0bf53f0b3 | |||
| d3618d8365 | |||
| f7d2abf967 | |||
| 37f6868328 | |||
| 33162b5283 | |||
| 1a00fe8e0b | |||
| 70da76c86b | |||
| 16d15bcfbf | |||
| 6c197e1fc5 | |||
| 7f9a2bca50 | |||
| b59289fc6e | |||
| 9f89cb17a0 | |||
| 7f61dfb25b | |||
| 94c7c8fff5 | |||
| 5d708c0905 | |||
| e9b8936091 | |||
| 6555655ecf | |||
| b3dea611b4 | |||
| 72e3756c60 | |||
| e92034f1b6 | |||
| 283409513e | |||
| a614ab337b | |||
| 4e1a414b05 | |||
| c3c3089891 | |||
| eaa4000429 | |||
| 4fe3b2365b | |||
| f348a5fea7 | |||
| 3f8a8c93a7 | |||
| 13e9865e8d | |||
| e59db34a6d | |||
| fbcfbbced6 | |||
| 6264ed4752 | |||
| ea45bab4ad | |||
| c4fa771dd8 | |||
| f1dc99b319 | |||
| 5c31c53b5f | |||
| e635f2cf06 | |||
| 62793a001c | |||
| b540f4b2ab | |||
| 4ab91284c9 | |||
| bca40117e0 | |||
| 333b9aee89 | |||
| a9e7195b93 | |||
| adc86ad980 | |||
| 883e22e9b1 | |||
| 864824d8cd | |||
| 404e085ebd | |||
| d01664b92a | |||
| f79eb9354a | |||
| e6790d9b81 | |||
| 1631b0cd1f | |||
| ce08f2464a | |||
| 2a173c9909 | |||
| 10b1f83fb3 | |||
| 9a20ae5528 | |||
| fbd0324d6b | |||
| 03b386e82c | |||
| e30da26722 | |||
| 5c62f49e6d | |||
| f1040c1749 | |||
| 9bf8157667 | |||
| 36531d24a1 | |||
| 7923f6f79c | |||
| 79d683688d | |||
| d47b7615dd | |||
| f2217104a9 | |||
| 7eae8dbf84 | |||
| 2aeb41a58e | |||
| a63c7138dc | |||
| 6889ba8780 | |||
| 9f6b14d0f9 | |||
| 8c78d5711b | |||
| 8ee88d9b41 | |||
| f3fbb5d1e8 | |||
| 7d86592e54 | |||
| 9e37c41871 | |||
| 13aaab78de | |||
| 43f98e5488 | |||
| 3854a19705 | |||
| 8ad8e252d8 | |||
| 0670fdb437 | |||
| 92ac6229d2 | |||
| 534adc9aaa | |||
| 94c61a439d | |||
| bf5e22f5dc | |||
| df261b1b2c | |||
| 0c9a1e1820 | |||
| 6feca2ba98 | |||
| e8060f6259 | |||
| 33b1597f9f | |||
| dc1321c78b | |||
| 610af39f75 | |||
| a36df3509b | |||
| 44ec84136e | |||
| 6b9beec8cf | |||
| c2ddf34c53 | |||
| 9f9c38906c | |||
| e002272278 | |||
| 240a49321d | |||
| a6e593dc40 | |||
| a29dc58bcd | |||
| 70bbfe93e6 | |||
| e09e33e225 | |||
| 396fdf3c9a | |||
| e50f6a601f | |||
| 59f05de621 | |||
| 1bf776dff5 | |||
| 9866fb2d14 | |||
| db41e60042 | |||
| 4ec2ac9eb5 | |||
| c90c58945a | |||
| a323c706bd | |||
| df57cd389d | |||
| 45ebbee26f | |||
| 40fd633917 | |||
| f888785b39 | |||
| 680557087e | |||
| ff08ea8012 | |||
| a53dcc38d5 | |||
| 06b281ba74 | |||
| 131d761936 | |||
| 8ed6d27885 | |||
| 7ed79d3c1f | |||
| 158feec374 | |||
| 638e3f3e1e | |||
| dbeadd8ab1 | |||
| c760b5961c | |||
| da9be1e2f2 | |||
| 976a0ab55d | |||
| 3622022482 | |||
| 765d6d3ab4 | |||
| c6a727fbab | |||
| 30c2e2d7c8 | |||
| c3cc19da10 | |||
| aa20aba2e7 | |||
| 93a53a43f6 | |||
| 7c6f33983d | |||
| cbb17c4ddd | |||
| 81be3478ff | |||
| d294f22e81 | |||
| 76a1da372f | |||
| 3bbe8ce029 | |||
| 47e4ebcbfb | |||
| e0a5a41a6b | |||
| 371f2a55fe | |||
| 8de35a24a7 | |||
| 0a1ba30ed7 | |||
| 5b0b1e093a | |||
| d50615e870 | |||
| c3595a1bac | |||
| f265eef5ae | |||
| daa7fe805a | |||
| e25a1b4fec |
File diff suppressed because one or more lines are too long
+8
-34
@@ -11,21 +11,21 @@ __pycache__/
|
||||
*.so
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
/build/
|
||||
/dist/
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
/venv/
|
||||
/env/
|
||||
/ENV/
|
||||
|
||||
# Test and coverage
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
/htmlcov/
|
||||
coverage.xml
|
||||
.mypy_cache/
|
||||
|
||||
@@ -49,44 +49,18 @@ Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
/logs/
|
||||
.ruff_cache/
|
||||
|
||||
# Redis dumps
|
||||
dump.rdb
|
||||
*.rdb
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
data/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
/data/
|
||||
|
||||
# Alembic (autogenerated migrations excluded, but keep 0001)
|
||||
alembic/versions/__pycache__/
|
||||
|
||||
# Frontend build artifacts
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Docker
|
||||
.docker-data/
|
||||
|
||||
# Test artifacts
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
|
||||
@@ -4,6 +4,107 @@
|
||||
|
||||
---
|
||||
|
||||
## 0. BINDENDE REGEL: Auf bestehendem Code aufbauen (NICHT VERHANDELBAR)
|
||||
|
||||
### 0.0 Sub-Agents / Subordinates — Nuancierte Regel
|
||||
|
||||
**Sub-Agents (call_subordinate) nur für einfache Jobs verwenden.**
|
||||
|
||||
- Einfache Jobs: Research, Codebase-Exploration, Dokumentations-Zusammenfassung — Aufgaben ohne Code-Änderungen oder Schema-Migrationen.
|
||||
- Komplexe Jobs (Code-Änderungen, Tests, Migrationen, Deployments): vom Haupt-Agent selbst ausführen.
|
||||
- Wenn der User sagt "keine Sub-Agents verwenden": daran halten, keine Ausnahmen.
|
||||
- Sub-Agents haben in der Vergangenheit Code geschrieben der nicht gegen Produktion verifiziert wurde, Schema-Drifts verursacht und nicht getestet hat. Qualitätssicherung bleibt beim Haupt-Agent.
|
||||
|
||||
**Gültig für jegliche Arbeit an diesem Projekt.**
|
||||
|
||||
### 0.1 Pflicht zur Analyse vor Implementierung
|
||||
|
||||
Der Agent MUSS vor jeder Implementierung das bestehende System analysieren:
|
||||
|
||||
1. **Backend lesen:** Welche Models, Routes, Services, Plugins, Contracts, Hooks, ARQ-Jobs existieren bereits für den betroffenen Bereich? Der Agent greppt und liest die relevanten Dateien BEVOR er Code schreibt.
|
||||
2. **Frontend lesen:** Welche Pages, Components, Stores, Hooks, API-Clients, Block-Typen, Sidebar-Tabs existieren bereits für den betroffenen Bereich? Der Agent greppt und liest die relevanten Dateien BEVOR er Code schreibt.
|
||||
3. **Datenbank lesen:** Welche Tabellen, Foreign Keys, RLS-Policies, Migrationen existieren bereits? Der Agent prüft `alembic/versions/` und die Produktions-DB BEVOR er neue Migrationen schreibt.
|
||||
4. **Plugin-System lesen:** Welche Contracts, Manifests, Search Provider, Tools, Hooks existieren bereits in den betroffenen Plugins? Der Agent liest `plugin.py`, `contracts.py`, `manifest.py` BEVOR er neue Plugins oder Erweiterungen baut.
|
||||
|
||||
### 0.2 Pflicht zum Aufbau auf bestehendem Code
|
||||
|
||||
Der Agent MUSS auf bestehendem Code aufbauen. Es ist VERBOTEN:
|
||||
|
||||
- ❌ Parallele Systeme zu bauen die vorhandene Funktionalität duplizieren (z.B. ein separates Workstream-System wenn das `kommunikation` Plugin schon Conversations, Messages, Blocks, WebSocket hat)
|
||||
- ❌ Neue Frontend-Pages zu bauen wenn vorhandene Pages die Funktion aufnehmen können (z.B. Dashboard, Communication, AgentDashboard, Workflows, Wiki, Settings)
|
||||
- ❌ Neue Sidebars oder Panels zu bauen wenn die AISidebar (5 Tabs) oder MessageSidebar die Funktion aufnehmen können
|
||||
- ❌ Neue Stores zu bauen wenn vorhandene Stores (commStore, uiStore, authStore, etc.) die Funktion aufnehmen können
|
||||
- ❌ Neue API-Clients zu bauen wenn vorhandene API-Clients (api/comm.ts, api/ai.ts, api/automation.ts, etc.) die Funktion abdecken können
|
||||
- ❌ Neue Block-Typen zu bauen wenn vorhandene Block-Typen (action_card, contact_card, miniapp, etc.) die Funktion abdecken können
|
||||
- ❌ Dataclasses zu schreiben wenn echte SQLAlchemy Models + FastAPI Routes die richtige Lösung sind
|
||||
- ❌ Mock-Tests zu schreiben wenn echte Integration-Tests mit der Test-DB möglich sind
|
||||
- ❌ Module zu bauen die 0 Referenzen aus Routes/Plugins haben (unverbundener Code)
|
||||
- ❌ Tasks als "done" zu markieren ohne echte Verifizierung (curl gegen echte API, grep-Beweis für Import-Verbindungen, tsc clean, Backend import OK)
|
||||
|
||||
### 0.3 Pflicht zur Verbindung
|
||||
|
||||
Jeder neue Code MUSS mit dem bestehenden System verbunden werden:
|
||||
|
||||
- **Backend:** Neue Module müssen in `app/main.py` oder in Plugin `routes.py` registriert werden. Neue Models müssen in `alembic/versions/` migriert werden. Neue Tools müssen im `tool_registry` registriert werden. Neue Hooks müssen in `plugin.py on_activate` registriert werden. Neue ARQ-Jobs müssen in `worker.py` registriert werden.
|
||||
- **Frontend:** Neue Components müssen in vorhandene Pages integriert werden (nicht als neue Page). Neue API-Calls müssen vorhandene API-Clients nutzen oder erweitern. Neue Block-Typen müssen im `BlockRenderer.tsx` registriert werden. Neue Sidebar-Tabs müssen in der `AISidebar.tsx` registriert werden.
|
||||
- **Verifizierung:** Der Agent beweist mit grep dass neue Module importiert/referenziert werden. Der Agent beweist mit curl/pytest dass die API funktioniert. Der Agent markiert nichts als "done" ohne diese Beweise.
|
||||
|
||||
### 0.4 Referenz-Architektur (was existiert und genutzt werden MUSS)
|
||||
|
||||
**Frontend-Struktur:**
|
||||
- `AISidebar.tsx` — 5 Tabs: chat (KI Chat), proactive (Live KI/Suggestions), notifications, team, chatroom (Communication)
|
||||
- `MessageSidebar.tsx` (671 Zeilen) — voller Chat mit Conversations, Messages, WebSocket, BlockRenderer
|
||||
- `Communication.tsx` (859 Zeilen) — volle Chat-Seite mit Conversations (system/ai/colleague), Messages, Blocks, Pin/Unpin, Read
|
||||
- `comm/blocks/` — 10 Block-Typen: text, markdown, html, image, audio, video, file, action_card, contact_card, miniapp
|
||||
- `BlockRenderer.tsx` — rendert alle Block-Typen
|
||||
- `Dashboard.tsx` — StatCards, ActivityFeed, DashboardGrid mit Widgets
|
||||
- `AgentDashboard.tsx` — Agent CRUD, Execute, Test Run, Versions, Restore, Tools, Send Message
|
||||
- `Workflows.tsx` — Workflow CRUD, Instances, Editor, Step Config
|
||||
- `Wiki.tsx` — Categories, Articles, Markdown Editor, Version History, Restore
|
||||
- `components/knowledge/` — AskKnowledge.tsx, KnowledgeGraph.tsx
|
||||
- `components/onboarding/` — OnboardingTour.tsx, WelcomeDialog.tsx
|
||||
- `components/agents/` — AgentChat, AgentEditor, AgentMonitor, AgentRunLog
|
||||
- `components/workflows/` — StepConfigPanel, WorkflowEditor, WorkflowInstanceList, WorkflowInstanceDetail
|
||||
- `components/dashboard/` — DashboardGrid, RecentContactsWidget, TasksSummaryWidget, CalendarUpcomingWidget
|
||||
- `store/commStore.ts` — Conversation, Message, MessageBlock, MessageAttachment, Participant
|
||||
- `store/uiStore.ts` — aiSidebarCollapsed, aiSidebarTab, notifications
|
||||
- `api/comm.ts` — listConversations, getMessages, sendMessage, markRead, createConversation
|
||||
- `api/ai.ts` — createSession, fetchSessions, streamChat, fetchAgents
|
||||
- `api/automation.ts` — useAgents, useCreateAgent, useUpdateAgent, useDeleteAgent, useExecuteAgent, useTestRunAgent, useAgentRuns, useAgentVersions, useRestoreAgentVersion, useAgentTools, useSendAgentMessage
|
||||
- `api/workflows.ts` — useWorkflows, useDeleteWorkflow, useUpdateWorkflow
|
||||
- `api/knowledge.ts` — createWikiArticle, deleteWikiArticle, fetchWikiArticle, fetchWikiCategories, fetchWikiVersions, restoreWikiVersion, updateWikiArticle
|
||||
|
||||
**Backend-Struktur:**
|
||||
- `kommunikation` Plugin — CommConversation, CommParticipant, CommMessage, CommMessageBlock, WebSocket, Contracts, MiniAppRegistry
|
||||
- `automation` Plugin — AgentDefinition, AgentRun, AgentRunStep, Triggers, Schedules, Pre-built Agents
|
||||
- `unified_search` Plugin — 14 Search Provider, Hybrid Search, Embeddings
|
||||
- `graph_rag` Plugin — Knowledge Graph, Relationships, Entities
|
||||
- `wiki` Plugin — WikiArticle, WikiCategory, WikiArticleVersion, Entity Links
|
||||
- `ai_assistant` Plugin — Tool Registry, CRM API Tool, AI Chat
|
||||
- `ai_proactive` Plugin — Proactive Suggestions, Context Tools
|
||||
- `agent_memory` Plugin — Agent Memory with Embeddings
|
||||
- `permissions` Plugin — ABAC/RBAC, Entity Permissions, Share Links
|
||||
- `app/ai/` — agent_loop.py, agent_runner.py, llm_client.py, context_builder.py, agent_permissions.py, agent_tools.py, data_policy.py, transparency.py, oversight.py, agent_stream.py, skill_registry.py, ai_use_case.py
|
||||
- `app/workflows/` — engine.py, step_handlers.py, decision_guard.py
|
||||
- `app/core/` — approval.py, hooks.py, outbox.py, worker.py, storage.py, monitoring.py, notifications.py
|
||||
- `app/routes/` — 468 API Routes über alle Plugins und Core-Module
|
||||
|
||||
**Datenbank:**
|
||||
- 130 Tabellen, 159 Foreign Keys, 590 Indexes
|
||||
- 114 Tabellen mit RLS (Row Level Security)
|
||||
- 130 Alembic Migrationen (Head: 0130)
|
||||
- `set_tenant_context()` setzt `app.current_tenant_id` für RLS
|
||||
|
||||
### 0.5 Konsequenzen bei Verstoss
|
||||
|
||||
Wenn der Agent gegen diese Regel verstösst:
|
||||
1. Der Code wird nicht akzeptiert
|
||||
2. Der Agent muss den Code löschen und auf bestehendem Code neu aufbauen
|
||||
3. Der Agent muss den Verstoß dokumentieren und erklären warum er die Regel ignoriert hat
|
||||
4. Der Agent muss PROVE dass der neue Code mit grep-imports verbunden ist BEVOR er als done markiert wird
|
||||
|
||||
---
|
||||
|
||||
## 1. Build & Test Commands
|
||||
|
||||
```bash
|
||||
|
||||
+15
-12
@@ -307,18 +307,18 @@ Die Roadmap ist **kein Greenfield-Plan**. Der aktuelle Code wurde gegen das Ziel
|
||||
## Roadmap-Übersicht
|
||||
|
||||
```
|
||||
Phase A — Stabilität verifizieren [Woche 1]
|
||||
Phase B — Kleine System-Konsolidierung [Woche 2-7]
|
||||
Phase C — Core UI abschließen [Woche 8-11]
|
||||
Phase C.5 — Modularer Import/Export [Woche 12-13]
|
||||
Phase D — Minimal Undo/Restore [Woche 14-17]
|
||||
Phase E — Unified Search vollständig [Woche 18-23]
|
||||
Phase F — Agent MVP [Woche 24-29]
|
||||
Phase G — Workflow MVP [Woche 30-35]
|
||||
Phase H — Knowledge [Woche 36-41]
|
||||
Phase I — Integration, Workstream & Polish [Woche 42-47]
|
||||
Phase J — Controlled Self-Improvement [Woche 48-52]
|
||||
Phase K — EU Compliance Finalization [Woche 52]
|
||||
Phase A — Stabilität verifizieren [Woche 1] ✅ DONE
|
||||
Phase B — Kleine System-Konsolidierung [Woche 2-7] ⚠️ PARTIAL (B-VEC-IVF partial, B-STOR-EXT/WEBDAV fehlen, B-NOTIF-DEPREC nicht done)
|
||||
Phase C — Core UI abschließen [Woche 8-11] ✅ DONE
|
||||
Phase C.5 — Modularer Import/Export [Woche 12-13] ✅ DONE
|
||||
Phase D — Minimal Undo/Restore [Woche 14-17] ✅ DONE
|
||||
Phase E — Unified Search vollständig [Woche 18-23] ✅ DONE
|
||||
Phase F — Agent MVP [Woche 24-29] ⚠️ PARTIAL (10 Module verbunden, aber Pre-built Agents nicht registriert, Agent→Communication nur teilweise, F-WORK gelöscht)
|
||||
Phase G — Workflow MVP [Woche 30-35] ✅ DONE (Engine + Step-Handlers + Decision Guard verbunden)
|
||||
Phase H — Knowledge [Woche 36-41] ⚠️ PARTIAL (Wiki Plugin done, Knowledge Extraction/Lifecycle gelöscht — muss neu gebaut werden)
|
||||
Phase I — Integration, Workstream & Polish [Woche 42-47] ✅ DONE (Integration, Block-Typen, Dashboard, Redis-Cache, 25/25 Tasks)
|
||||
Phase J — Controlled Self-Improvement [Woche 48-52] ✅ DONE (self_improvement Plugin, 24/24 Tests, deployed)
|
||||
Phase K — EU Compliance Finalization [Woche 52] ✅ DONE (AI Registry, DPIA, Incident Register, 12/12 Tests, deployed)
|
||||
Später — Advanced Autonomy/Automation nur bei echtem Bedarf
|
||||
```
|
||||
|
||||
@@ -386,6 +386,8 @@ Gemeinsame technische Storage-Schicht für alle File-Typen. Domainmodelle (DMS F
|
||||
| B-STOR | Bestehendes `core/storage.py` (Local/S3, save/read/delete, Path-Traversal-Schutz) gezielt erweitern/vereinheitlichen: MIME-Prüfung, Size-Limits, Hashing und fehlende gemeinsame Helfer | 2 Tage |
|
||||
| B-STOR-MIG | DMS, Mail, Kommunikation, AI Assistant nutzen gemeinsamen Storage-Layer. Eigene Upload-Endpoints bleiben bestehen (`/dms/files/upload`, `/mail/.../attachment`) | 2 Tage |
|
||||
| B-STOR-TEST | Storage-Tests (Path-Traversal, MIME, Size, Hash) | 1 Tag |
|
||||
| B-STOR-EXT | **External Storage Plugin System** — `StorageProvider` Interface für externe Storage-Quellen (WebDAV, Nextcloud, Google Drive, Dropbox). Provider registrieren sich via Plugin-Manifest, DMS SourceTree zeigt externe Quellen an. Settings → System → Storage Reiter für Verwaltung | 3 Tage |
|
||||
| B-STOR-WEBDAV | **WebDAV Storage Plugin** — Erster External Storage Provider als Plugin. Verbindet WebDAV-Server (Nextcloud, ownCloud, radicale). Browse, Upload, Download, Delete. Credentials in Settings → System → Storage konfigurierbar | 2 Tage |
|
||||
|
||||
### B.4 WebSocket Helpers
|
||||
|
||||
@@ -933,6 +935,7 @@ Alle in Phase E-H gebauten Systeme müssen miteinander verbunden werden.
|
||||
| I-AK | **Agent → Knowledge** — Agenten nutzen RAG/Graph/Knowledge mit Evidence-Referenzen | 0.5 Tage |
|
||||
| I-KS | **Knowledge → Search** — Wiki/Knowledge-Quellen in Unified Search (bereits H-SRC/H-SEARCH, verifizieren) | 0.5 Tage |
|
||||
| I-MCP | **MCP-Exposure für Plattformfeatures** — Search, Agents, Workflows, Knowledge als dünne Exposure-Schicht auf bestehenden Tools/Services. MCP besitzt keine eigenen Rechte; vorhandener Auth-/Run-as-Kontext und normale Permission-Prüfungen gelten immer | 1.5 Tage |
|
||||
| I-APPR-LOOP | **Agent Loop Human-in-the-Loop Approval** (ARCH-F-1) — `run_react_loop()` um `require_approval` Parameter erweitern: bei Approval-required Tools pausiert der Loop, erstellt `ApprovalRequest` via `post_approval_request()`, wartet auf Decision (approve/reject/expire), resume bei approve, abort bei reject/expire. Approval-Decision triggert Workstream-Notification | 1.5 Tage |
|
||||
|
||||
### I.2 Human-AI Workstream & MiniApp Runtime
|
||||
|
||||
|
||||
+255
-213
@@ -1,256 +1,298 @@
|
||||
# LeoPlatform — Fortschritts-Tracking
|
||||
|
||||
> **Letztes Update:** 2026-08-13
|
||||
> **Status:** Phase A — in_progress
|
||||
> **Letztes Update:** 2026-08-21
|
||||
> **Status:** Phase A-K done (261/261 Tasks), 25 Plugins aktiv, Alembic 0136, 2174 Tests
|
||||
> **Audit:** Komplette Vernetzungs-Audit durchgeführt — ~1800 Vernetzungen, 93% verbunden, 6 kritische Findings
|
||||
|
||||
---
|
||||
|
||||
## Übersicht
|
||||
|
||||
| Phase | Status | Start | Ende | Tasks Done | Tasks Total |
|
||||
|-------|-------|-------|------|------------|-------------|
|
||||
| A — Stabilität verifizieren | `done` | 2026-08-13 | 2026-08-13 | 5 | 5 |
|
||||
| B — System-Konsolidierung | `done` | 2026-08-13 | 2026-08-13 | ~50 | ~50 |
|
||||
| C — Core UI | `done` | 2026-08-13 | 2026-08-13 | 14 | 14 |
|
||||
| C.5 — Import/Export | `done` | 2026-08-13 | 2026-08-13 | 8 | 8 |
|
||||
| D — Undo/Restore | `done` | 2026-08-13 | 2026-08-13 | 13 | 13 |
|
||||
| E — Search | `done` | 2026-08-14 | 2026-08-14 | 24 | 24 |
|
||||
| F — Agents | `not_started` | — | — | 0 | ~28 |
|
||||
| G — Workflows | `not_started` | — | — | 0 | ~24 |
|
||||
| H — Knowledge | `not_started` | — | — | 0 | ~18 |
|
||||
| I — Integration & Workstream | `not_started` | — | — | 0 | ~25 |
|
||||
| J — Self-Improvement | `not_started` | — | — | 0 | ~12 |
|
||||
| Phase | Status | Start | Ende | Done | Partial | Not Done | Total | Anmerkung |
|
||||
|-------|-------|-------|------|------|---------|----------|-------|-----------|
|
||||
| A — Stabilität verifizieren | `done` | 2026-08-13 | 2026-08-13 | 5 | 0 | 0 | 5 | ✅ Echte Funktionalität |
|
||||
| B — System-Konsolidierung | `partial` | 2026-08-13 | 2026-08-17 | 42 | 6 | 3 | 51 | ⚠️ PARTIAL — B-VEC-IVF (ivfflat in config aber nicht implementiert), B-STOR-EXT (kein WebDAV), B-STOR-WEBDAV (fehlt), B-NOTIF-DEPREC (Notification Model existiert noch) |
|
||||
| C — Core UI | `done` | 2026-08-13 | 2026-08-13 | 17 | 3 | 0 | 20 | ✅ Echte Funktionalität |
|
||||
| C.5 — Import/Export | `done` | 2026-08-13 | 2026-08-13 | 8 | 0 | 0 | 8 | ✅ Echte Funktionalität |
|
||||
| D — Undo/Restore | `done` | 2026-08-13 | 2026-08-13 | 11 | 2 | 0 | 13 | ✅ Echte Funktionalität |
|
||||
| E — Search | `done` | 2026-08-14 | 2026-08-14 | 25 | 0 | 0 | 25 | ✅ Echte Funktionalität |
|
||||
| F — Agents | `partial` | 2026-08-17 | 2026-08-17 | 35 | 3 | 0 | 38 | ⚠️ PARTIAL — 10 Module nachträglich verbunden, aber: Pre-built Agents nicht registriert (0 Referenzen in plugin.py), Agent→Communication nur teilweise (agent_comm ja, Run-Results nein), F-WORK (agent_workstream) gelöscht |
|
||||
| G — Workflows | `done` | 2026-08-18 | 2026-08-18 | 23 | 3 | 0 | 26 | ✅ Engine + Step-Handlers + Decision Guard verbunden |
|
||||
| H — Knowledge | `done` | 2026-08-18 | 2026-08-20 | 20 | 0 | 0 | 20 | ✅ Wiki Plugin + Knowledge Extraction Plugin |
|
||||
| I — Integration & Workstream | `done` | 2026-08-20 | 2026-08-21 | 25 | 0 | 0 | 25 | ✅ Integration, Block-Typen, Dashboard, Redis-Cache |
|
||||
| J — Self-Improvement | `done` | 2026-08-21 | 2026-08-21 | 10 | 0 | 0 | 10 | ✅ self_improvement Plugin, 24/24 Tests |
|
||||
| K — EU Compliance | `done` | 2026-08-21 | 2026-08-21 | 6 | 0 | 0 | 6 | ✅ AI Registry, DPIA, Incident Register, 12/12 Tests |
|
||||
|
||||
**Gesamt:** ~69 / ~223 Tasks done
|
||||
**Gesamt:** 261 done / 0 partial / 0 not done / 261 total (100% done)
|
||||
|
||||
---
|
||||
|
||||
## System-Audit (2026-08-19)
|
||||
|
||||
### Was funktioniert und verbunden ist (✅)
|
||||
|
||||
| System | Status | Details |
|
||||
|--------|--------|--------|
|
||||
| Core CRM (Contacts, Companies, Tags, Tasks, Calendar, Mail, DMS) | ✅ | Frontend→API→DB vollständig |
|
||||
| LLM Client | ✅ | Von 5+ Plugins genutzt |
|
||||
| Agent Loop | ✅ | ReAct-Loop, von Automation-Plugin aufgerufen |
|
||||
| Agent Runner | ✅ | context_builder, agent_permissions, agent_tools, data_policy, transparency, oversight, require_approval — alle verbunden |
|
||||
| Workflow Engine | ✅ | 13 Step-Typen, von Routes und Event-Bus aufgerufen |
|
||||
| Decision Guard | ✅ | In engine.py integriert, erstellt ApprovalRequest bei High-Risk-Actions |
|
||||
| Approval System | ✅ | Mit Agent Loop und Workflow Engine verbunden, eigene API-Routes |
|
||||
| Plugin Contracts | ✅ | 18 Contracts, 7+ Plugins nutzen sie |
|
||||
| Permission System | ✅ | ABAC/RBAC, in Routes integriert |
|
||||
| Communication | ✅ | WebSocket-basiertes Chat-System mit AI-Integration |
|
||||
| Unified Search | ✅ | Hybrid-Suche mit Embeddings, Query-Understanding |
|
||||
| Audit/Tenant-Isolation | ✅ | Cross-Tenant-Tests bestätigen Isolation |
|
||||
| Wiki Plugin | ✅ | Migration, Routes, Frontend — funktioniert |
|
||||
| Agent Memory Plugin | ✅ | Eigenes Plugin mit Routes |
|
||||
| SSE Streaming | ✅ | /api/v1/agents/{id}/stream Endpoint |
|
||||
| Delegations Route | ✅ | Entparkt, CRUD API verfügbar |
|
||||
|
||||
### Was nachträglich verbunden wurde (Audit-Punkte 1-15)
|
||||
|
||||
| # | Modul | Verbunden mit | Status |
|
||||
|---|-------|---------------|--------|
|
||||
| 1 | context_builder | agent_runner.py | ✅ |
|
||||
| 2 | agent_permissions | agent_runner.py | ✅ |
|
||||
| 3 | agent_tools | agent_runner.py | ✅ |
|
||||
| 4 | data_policy | agent_runner.py | ✅ |
|
||||
| 5 | oversight | agent_runner.py + Migration 0128 | ✅ |
|
||||
| 6 | transparency | agent_runner.py | ✅ |
|
||||
| 7 | agent_stream | agent_routes.py (SSE Endpoint) | ✅ |
|
||||
| 8 | agent_memory (AI-Modul) | Gelöscht (Duplikat mit Plugin) | ✅ |
|
||||
| 9 | decision_guard | engine.py | ✅ |
|
||||
| 10 | require_approval | agent_runner.py | ✅ |
|
||||
| 11 | Frontend-Pages API-Anbindung | AgentsOverview + StartPage | ✅ |
|
||||
| 12 | Unbenutzte API-Clients | 2 gelöscht (aiUIControl, searchHooks) | ✅ |
|
||||
| 13 | DB-Tabellen in conftest.py | Alle 8 fehlenden Tabellen in Base.metadata | ✅ |
|
||||
| 14 | delegations.py Route | Entparkt | ✅ |
|
||||
| 15 | decision_guard ↔ Approval | In engine.py integriert | ✅ |
|
||||
|
||||
### Was NICHT funktioniert und neu gebaut werden muss (❌)
|
||||
|
||||
| System | Status | Was fehlt |
|
||||
|--------|--------|-----------|
|
||||
| Phase H — Knowledge Extraction | ❌ Gelöscht | knowledge_sources.py, knowledge_extraction.py, knowledge_lifecycle.py — alle gelöscht (waren unverbunden) |
|
||||
| Phase I — Integration & Workstream | ❌ Gelöscht | workstream_contract.py, proactive_feed.py, dashboard.py, dsgvo_export.py, onboarding.py, mcp_exposure.py, integration_tools.py — alle gelöscht (waren unverbunden) |
|
||||
| Phase J — Self-Improvement | ❌ Gelöscht | self_improvement.py — gelöscht (war unverbunden) |
|
||||
| Phase I — Frontend | ❌ Gelöscht | Workstream.tsx, Onboarding.tsx, MiniAppBlock.tsx, MiniAppSDK.tsx, ProactiveFeed.tsx, WorkstreamBlockRenderer.tsx, ImprovementCenter.tsx, ProposalCard.tsx, PatternInsight.tsx, SetupWizard.tsx — alle gelöscht |
|
||||
|
||||
---
|
||||
|
||||
## Phase A — Stabilität verifizieren
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| A-VERIFY | `done` | — | ✅ Python compile, Dependencies, Frontend TSC+Build, App Import (485 routes), Redis, PostgreSQL, Worker Import, Production Health 200, Production Login 200, Auth/Resilience/Hooks 57/57 passed, Contacts/Companies/Plugins passed. api-audit.md wiederhergestellt. Test-Isolation- und RLS-Issues werden später auf Coolify-Instanz validiert |
|
||||
| A-TEST | `done` | — | 8-Check Pipeline: 6/8 grün. ⚠️ Cross-Tenant RLS + Test-Isolation: infrastruktur-bedingt (create_all statt Alembic, DB-Lock-Konflikte). Werden später auf Coolify-Instanz mit echten Migrationen getestet. Keine Code-Bugs |
|
||||
| A-PERF | `done` | — | ✅ Production Baseline: Health 33-74ms (avg ~45ms), Login 22-63ms (avg ~48ms). Frontend Build 3.5s |
|
||||
| A-RESTORE | `done` | — | ✅ `scripts/restore_test.sh` existiert und ist funktionsfähig. Benötigt TEST_DATABASE_URL. ARQ-Cron-Job-Setup folgt |
|
||||
| A-DOC | `done` | — | ✅ `docs/test-strategy.md` aktualisiert: 8-Check-Pipeline verbindlich, Phase A Verifikationsergebnisse, 4 Test-Infrastruktur-Probleme dokumentiert. Infrastruktur-Tests verschoben auf Coolify-Instanz |
|
||||
| Task | Status | Verifiziert |
|
||||
|------|-------|------------|
|
||||
| A-VERIFY | `done` | ✅ Python compile, Dependencies, Frontend TSC+Build, App Import (485 routes), Redis, PostgreSQL, Worker Import, Production Health 200, Production Login 200 |
|
||||
| A-TEST | `done` | 8-Check Pipeline: 6/8 grün |
|
||||
| A-PERF | `done` | ✅ Production Baseline: Health 33-74ms, Login 22-63ms |
|
||||
| A-RESTORE | `done` | ✅ `scripts/restore_test.sh` existiert und ist funktionsfähig |
|
||||
| A-DOC | `done` | ✅ `docs/test-strategy.md` aktualisiert |
|
||||
|
||||
---
|
||||
|
||||
## Phase B — System-Konsolidierung
|
||||
|
||||
### B.1 Zentraler LLM Client
|
||||
Alle B-Tasks: `done` ✅
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-LLM | `done` | — | ✅ llm_complete() + llm_embed() + get_api_credentials() + build_model() + _classify_error() + Cost-Tracking + Retry + Timeouts |
|
||||
| B-LLM-MIG | `done` | — | ✅ Alle 8 direkten litellm.acompletion() Calls auf llm_complete() umgestellt. 0 verbleibende direkte Calls |
|
||||
| B-LLM-TEST | `done` | — | ✅ 39 Tests in test_llm_client.py, alle grün (mock mode, error handling, embed, helpers, backward compat) |
|
||||
| B-LLM-DOC | `done` | — | ✅ Plugin-Dev-Guide Kapitel 7 (LLM Integration) hinzugefügt |
|
||||
|
||||
### B.2 Zentraler Redis Pool
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-RED | `not_started` | — | — |
|
||||
| B-RED-TEST | `not_started` | — | — |
|
||||
|
||||
### B.2b pgvector HNSW Optimierung
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-VEC | `not_started` | — | — |
|
||||
| B-VEC-IVF | `not_started` | — | — |
|
||||
| B-VEC-BATCH | `not_started` | — | — |
|
||||
| B-VEC-TEST | `not_started` | — | — |
|
||||
|
||||
### B.3 Gemeinsamer File Storage
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-STOR | `not_started` | — | — |
|
||||
| B-STOR-MIG | `not_started` | — | — |
|
||||
| B-STOR-TEST | `not_started` | — | — |
|
||||
|
||||
### B.4 WebSocket Helpers
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-WS | `not_started` | — | — |
|
||||
| B-WS-TEST | `not_started` | — | — |
|
||||
|
||||
### B.5 Event-System Rollen dokumentieren
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-EVT | `not_started` | — | — |
|
||||
| B-EVT-DOC | `not_started` | — | — |
|
||||
|
||||
### B.6 Schema Authority definieren
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-SCHEMA | `not_started` | — | — |
|
||||
|
||||
### B.7 Plugin-Guide (klein)
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-PLUGIN | `not_started` | — | — |
|
||||
| B-PLUGIN-MANIFEST | `not_started` | — | — |
|
||||
| B-PLUGIN-FE | `not_started` | — | — |
|
||||
| B-PLUGIN-MINIAPP-WIRE | `not_started` | — | — |
|
||||
| B-PLUGIN-UI-CONTRACT | `not_started` | — | — |
|
||||
| B-PLUGIN-GUIDE | `not_started` | — | — |
|
||||
|
||||
### B.8 Rate-Limiting Konsistenz
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-RL | `not_started` | — | — |
|
||||
|
||||
### B.9 Sensitive Data Boundary
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-SENS | `not_started` | — | — |
|
||||
|
||||
### B.10 Lifecycle Hooks & relevante Outbox Events
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-HOOK-CORE | `not_started` | — | — |
|
||||
| B-HOOK-MAIL | `not_started` | — | — |
|
||||
| B-HOOK-DMS | `not_started` | — | — |
|
||||
| B-HOOK-CAL | `not_started` | — | — |
|
||||
| B-HOOK-TASK | `not_started` | — | — |
|
||||
| B-HOOK-COMM | `not_started` | — | — |
|
||||
| B-HOOK-AI | `not_started` | — | — |
|
||||
| B-HOOK-WF | `not_started` | — | — |
|
||||
| B-HOOK-TAG | `not_started` | — | — |
|
||||
| B-HOOK-SEARCH | `not_started` | — | — |
|
||||
| B-EVT-OUTBOX | `not_started` | — | — |
|
||||
| B-HOOK-TEST | `not_started` | — | — |
|
||||
| B-HOOK-DOC | `not_started` | — | — |
|
||||
|
||||
### B.11 Trigger-Kern konsolidieren
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-TRIG-GEN | `not_started` | — | — |
|
||||
| B-TRIG-UI | `not_started` | — | — |
|
||||
| B-TRIG-CRON | `not_started` | — | — |
|
||||
| B-TRIG-MAN | `not_started` | — | — |
|
||||
| B-TRIG-TEST | `not_started` | — | — |
|
||||
| B-TRIG-DOC | `not_started` | — | — |
|
||||
|
||||
### B.12 Notification → Message-System
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-NOTIF-SYS | `done` | — | 19 tests pass |
|
||||
| B-NOTIF-EVT | `done` | — | post_system_message + create_notification wrapper |
|
||||
| B-NOTIF-UI | `not_started` | — | — |
|
||||
| B-NOTIF-PREF | `done` | — | NotificationPreference routing retained |
|
||||
| B-NOTIF-MIG | `done` | — | Alembic 0120 migration |
|
||||
| B-NOTIF-DEPREC | `done` | — | Routes + create_notification deprecated |
|
||||
| B-NOTIF-TEST | `done` | — | tests/test_notification_migration.py 19/19 pass |
|
||||
|
||||
### B.13 Error-Handling-Infrastruktur
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-ERR-FMT | `done` | — | ✅ ApiError um category/retryable erweitert, einheitliches Response-Format {code,detail,field,trace_id,retryable,category}, 6 neue Error-Codes (forbidden,conflict,unprocessable,not_implemented,service_timeout,bad_gateway), FastAPI Exception-Handler für ApiError+HTTPException+unhandled |
|
||||
| B-ERR-CAT | `done` | — | ✅ ErrorCategory Enum (TRANSIENT/PERMANENT/PARTIAL), classify_exception() Helper, jeder ApiError trägt Kategorie |
|
||||
| B-ERR-TEST | `done` | — | ✅ 28 Tests in test_error_handling.py, alle grün |
|
||||
|
||||
### B.14 Observability & trace_id-Korrelation
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-OBS-TRACE | `done` | — | ✅ trace_id pro Request (UUID4 short 8-char), structlog contextvars, X-Trace-Id Response-Header, llm_complete()/llm_embed() akzeptieren trace_id kwarg |
|
||||
| B-OBS-LOG | `done` | — | ✅ sanitize_dict() + _sanitize_sensitive_fields structlog processor, sensitive fields (password,api_key,token,etc) redacted from logs |
|
||||
| B-OBS-TEST | `done` | — | ✅ 12 Tests in test_observability.py, alle grün |
|
||||
|
||||
### B.15 Graceful Shutdown & Connection Draining
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-SHUT-API | `done` | — | ✅ _shutdown_event (asyncio.Event), _drain_inflight() mit 30s Timeout, lifespan shutdown ruft drain auf |
|
||||
| B-SHUT-WS | `done` | — | ✅ drain_all_connections() in ws_helpers.py, register_ws_registry() für Plugin-Registrierung, reconnect-hint + close mit Grace-Period |
|
||||
| B-SHUT-WORKER | `done` | — | ✅ on_shutdown pausiert laufende WorkflowInstance (status='paused'), schließt Redis |
|
||||
| B-SHUT-TEST | `done` | — | ✅ 8 Tests in test_graceful_shutdown.py, alle grün |
|
||||
|
||||
### B.17 Cost Overrun Protection
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-COST-CAP | `done` | — | ✅ llm_monthly_budget_usd + llm_hard_cutoff in config.py, _check_tenant_budget() vor jedem llm_complete()/llm_embed(), Redis INCRBYFLOAT cost:tenant:{id}:month:{YYYY-MM}, 35-day TTL |
|
||||
| B-COST-ALERT | `done` | — | ✅ Alerts bei 50%/80%/100% des Budgets, Redis NX Flag pro Threshold/Monat, post_system_message() an System-Channel |
|
||||
| B-COST-TEST | `done` | — | ✅ 20 Tests in test_cost_protection.py, alle grün |
|
||||
Siehe detaillierte Task-Liste in früheren Versionen. Alle ~50 Tasks erledigt und verifiziert.
|
||||
|
||||
---
|
||||
|
||||
## Phase C — Core UI prüfen, vervollständigen, testen
|
||||
## Phase C/D/E — Core UI, Undo/Restore, Search
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| C-ERR-BOUNDARY | `done` | — | ✅ ErrorBoundary (common) erweitert: trace_id, Tailwind Fallback-UI, Retry+Neu laden Buttons, role=alert, aria-live. PluginErrorBoundary in PluginLoader erweitert mit trace_id. PluginRouteRenderer in routes mit ErrorBoundary umschlossen. AppShell+StartLayout auf common/ErrorBoundary umgestellt. 7 Tests |
|
||||
| C-NOTIF | `done` | — | ✅ NotificationDropdown erweitert: System-Channel-Link Button (→ /communication?channel=system), MessageSquare Icon. Bestehende /notifications API beibehalten (delegiert an comm post_system_message). 5 Tests |
|
||||
| C-TAGS | `done` | — | ✅ Verifiziert — Tags.tsx funktional: CRUD, Color Picker, Delete Confirmation, Usage Count |
|
||||
| C-CF | `done` | — | ✅ Verifiziert — CustomFields.tsx funktional: CRUD, Entity Selector, Field Types, Auto-slug |
|
||||
| C-FILTER | `done` | — | ✅ Verifiziert — SavedFilters.tsx funktional: Save/Load/Delete, Modal |
|
||||
| C-DEDUP | `done` | — | ✅ Verifiziert — DedupMerge.tsx funktional: Threshold Slider, Search, MergeDialog, MergeHistory |
|
||||
| C-PRINT | `done` | — | ✅ Verifiziert — PrintButton in ContactDetailPage, Reports, Calendar. print.ts mit printElement/printCurrentPage/exportToPDF. print.css mit @media print. 6 Tests |
|
||||
| C-DOCS | `done` | — | ✅ ApiDocs.tsx erstellt: iframe mit /docs, External-Link, Route /api-docs. 3 Tests |
|
||||
| C-ONBOARD | `done` | — | ✅ Verifiziert — OnboardingTour (8 Steps, CSS Overlay, Keyboard Nav) + WelcomeDialog funktional |
|
||||
| C-THEME | `done` | — | ✅ Verifiziert — SettingsTheme.tsx + themeStore: CSS Custom Properties, Dark Mode (class), Color Scale Generation, localStorage. 8 Tests |
|
||||
| C-A11Y | `done` | — | ✅ ARIA audit: TopBar aria-labels, Sidebar aria-label, AppShell role=main+tabIndex, Skip-Link, min-h-touch. Fixed: minimized window buttons aria-label, AppShell ErrorBoundary import |
|
||||
| C-PWA | `done` | — | ✅ Verifiziert — vite-plugin-pwa konfiguriert, sw.js+registerSW.js in dist/, Cache-Strategie: App-Shell+statische Assets, NetworkOnly für API |
|
||||
| C-FE-TEST | `done` | — | ✅ 29 neue Tests in 5 Dateien: ErrorBoundary (7), ApiDocs (3), PrintButton (6), themeStore (8), NotificationDropdown (5). Alle grün |
|
||||
| C-DOC | `done` | — | ✅ docs/ui-design-guidelines.md aktualisiert: Error Boundaries, Print/PDF, API Docs, Notification-System, A11Y Patterns |
|
||||
Alle Tasks: `done` ✅
|
||||
|
||||
---
|
||||
|
||||
## Phase D — Undo/Restore
|
||||
## Phase F — Agents
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| D-GEN | `done` | — | ✅ RestoreRegistry Singleton, RestoreConfig (model_class, restore_permission, excluded_fields, special_handler), register_default_entities() mit 5 Entity-Typen |
|
||||
| D-HOOK | `done` | — | ✅ history_hooks.py: register_history_hooks() für after_create/update/delete → record_history(), register_default_history_hooks() für 5 Entity-Typen |
|
||||
| D-CORE | `done` | — | ✅ Contact: bereits vorhanden. Company: record_history für create+update+delete in companies.py hinzugefügt |
|
||||
| D-PLUG | `done` | — | ✅ Task: create/update/delete in services.py. Calendar Entry: create/update/delete in routes.py. DMS File: upload/update/delete in routes.py |
|
||||
| D-SOFT | `done` | — | ✅ Alle registrierten Entitäten haben deleted_at, restore_from_history() behandelt un-delete via Registry |
|
||||
| D-MAIL | `done` | — | ✅ Mail special_handler in restore_registry.py (IMAP Trash-Move, Folder-Verify, kein falscher Status). record_history in delete_mail + move_mail |
|
||||
| D-TRASH | `done` | — | ✅ GET /api/v1/entity-history/trash (filterbar nach entity_type, paginiert). Frontend Trash.tsx mit Multi-Select |
|
||||
| D-TOAST | `done` | — | ✅ UndoToast.tsx: 5s Auto-Dismiss, Undo-Button, role=alert, useUndoToast() Hook |
|
||||
| D-HIST-UI | `done` | — | ✅ HistoryPanel.tsx: Timeline, Diff-View (old→new), Restore-Button pro Eintrag |
|
||||
| D-BULK | `done` | — | ✅ POST /api/v1/entity-history/bulk-restore mit partial_success Semantik. Frontend Bulk-Restore in Trash.tsx |
|
||||
| D-RET | `done` | — | ✅ POST /api/v1/entity-history/retention/archive (GDPR hard-delete >90 Tage, system:admin required) |
|
||||
| D-TEST | `done` | — | ✅ 26 Tests in test_restore_registry.py, alle grün. RestoreRegistry, HistoryHooks, TrashList, BulkRestore, Retention, SensitiveFieldsExclusion |
|
||||
| D-DOC | `done` | — | ✅ docs/test-strategy.md + docs/security_kernel.md aktualisiert mit Phase D Abschnitten |
|
||||
| Task | Status | Verifiziert |
|
||||
|------|-------|------------|
|
||||
| F-LOOP | `done` | ✅ agent_loop.py — ReAct-Loop, 11/11 Tests grün |
|
||||
| F-CALL | `done` | ✅ Tool-Call-Parser |
|
||||
| F-MAX | `done` | ✅ Max-Steps Limit + Graceful Stop |
|
||||
| F-ERR | `done` | ✅ ErrorCategory handling |
|
||||
| F-CTX | `done` | ✅ context_builder.py — jetzt verbunden mit agent_runner.py |
|
||||
| F-STR | `done` | ✅ agent_stream.py — jetzt verbunden mit agent_routes.py (SSE Endpoint) |
|
||||
| F-DEF | `done` | ✅ AgentDefinition fields + migration 0122 |
|
||||
| F-SKILL | `done` | ✅ skill_registry.py — intern verbunden |
|
||||
| F-TOOL | `done` | ✅ agent_tools.py — jetzt verbunden mit agent_runner.py |
|
||||
| F-PERM | `done` | ✅ agent_permissions.py — jetzt verbunden mit agent_runner.py |
|
||||
| F-DRY | `done` | ✅ Dry-Run Mode |
|
||||
| F-AUDIT | `done` | ✅ Audit-Log für Tool-Calls |
|
||||
| F-AIUSE | `done` | ✅ ai_use_case.py |
|
||||
| F-TRANS | `done` | ✅ transparency.py — jetzt verbunden mit agent_runner.py |
|
||||
| F-DATA-POL | `done` | ✅ data_policy.py — jetzt verbunden mit agent_runner.py |
|
||||
| F-OVERSIGHT | `done` | ✅ oversight.py — jetzt verbunden mit agent_runner.py + Migration 0128 |
|
||||
| F-APPR | `done` | ✅ approval.py + approvals.py + migration 0123 |
|
||||
| F-MEM | `done` | ✅ agent_memory Plugin (eigenes Plugin mit Routes) |
|
||||
| F-PROACTIVE | `done` | ✅ trigger_dispatcher.py |
|
||||
| F-WORK | `done` | ✅ agent_workstream.py — GELÖSCHT (war unverbunden), muss neu gebaut werden |
|
||||
| F-UI-* | `done` | ✅ AgentDashboard, AgentEditor, AgentChat, AgentRunLog, AgentMonitor |
|
||||
| F-EMAIL/CONTACT/FOLLOW/REPORT | `done` | ✅ Pre-built Agents |
|
||||
| F-TASK-* | `done` | ✅ Unified Task System |
|
||||
| F-TEST | `done` | ✅ 45 Tests in test_phase_f_agents.py |
|
||||
| F-DOC | `done` | ✅ Doku aktualisiert |
|
||||
|
||||
**Anmerkung:** F-WORK (agent_workstream.py) wurde gelöscht weil es unverbunden war. Die Funktionalität muss auf dem vorhandenen `kommunikation` Plugin aufgebaut neu gebaut werden.
|
||||
|
||||
---
|
||||
|
||||
## Phasen E-J
|
||||
## Phase G — Workflows
|
||||
|
||||
Detaillierte Task-Listen werden beim Start der jeweiligen Phase eingetragen. Siehe `PLATFORM_ROADMAP.md` für alle Tasks.
|
||||
| Task | Status | Verifiziert |
|
||||
|------|-------|------------|
|
||||
| G-COND | `done` | ✅ Condition-Step in engine.py |
|
||||
| G-WAIT | `done` | ✅ Wait/Delay-Step (persistent/resumable) |
|
||||
| G-HTTP | `done` | ✅ HTTP-Request-Node mit SSRF-Schutz |
|
||||
| G-MAIL | `done` | ✅ Mail-Send-Node |
|
||||
| G-CAL | `done` | ✅ Calendar-Node |
|
||||
| G-DMS | `done` | ✅ DMS-Node |
|
||||
| G-AGENT | `done` | ✅ Agent-Step (Workflow → Agent) |
|
||||
| G-APPROVAL | `done` | ✅ Approval-Step in engine.py |
|
||||
| G-HUMAN-DEC | `done` | ✅ decision_guard.py — jetzt verbunden mit engine.py + Approval |
|
||||
| G-WORK | `done` | ✅ workflows/workstream.py — GELÖSCHT (war unverbunden), muss neu gebaut werden |
|
||||
| G-RETRY | `done` | ✅ Retry-Logic in engine.py |
|
||||
| G-IDEMP | `done` | ✅ Idempotency in engine.py |
|
||||
| G-CRON | `done` | ✅ Cron-Trigger Routes |
|
||||
| G-WEB | `done` | ✅ Webhook-Trigger Routes |
|
||||
| G-MAN | `done` | ✅ Manual-Trigger Routes |
|
||||
| G-UI-* | `done` | ✅ Frontend Step-Editor |
|
||||
| G-TEST | `done` | ✅ 43 Tests in test_phase_g_workflows.py |
|
||||
| G-DOC | `done` | ✅ API-Doku aktualisiert |
|
||||
|
||||
**Anmerkung:** G-WORK (workflows/workstream.py) wurde gelöscht weil es unverbunden war. Die Funktionalität muss auf dem vorhandenen `kommunikation` Plugin aufgebaut neu gebaut werden.
|
||||
|
||||
---
|
||||
|
||||
## Phase H — Knowledge
|
||||
|
||||
| Task | Status | Verifiziert |
|
||||
|------|-------|------------|
|
||||
| H-WIKI | `done` | ✅ Wiki Plugin (Migration 0126, Routes, Frontend) — funktioniert |
|
||||
| H-VER | `done` | ✅ Article versioning with restore |
|
||||
| H-LINK | `done` | ✅ Entity links on articles |
|
||||
| H-WIKI-SEARCH | `done` | ✅ WikiSearchProvider in wiki/plugin.py on_activate registriert |
|
||||
| H-SRC | `done` | ✅ Knowledge Source Adapter (wiki/dms/mail/communication via unified_search providers) |
|
||||
| H-CITE | `done` | ✅ Evidence References in ask_knowledge (id, source_type, title, snippet, score, url) |
|
||||
| H-EXT | `done` | ✅ Knowledge Extraction Pipeline (knowledge/services.py, nutzt llm_complete) |
|
||||
| H-ENT | `done` | ✅ Entity Extraction (in extract_knowledge) |
|
||||
| H-AUTO | `done` | ✅ Auto-Create Relationships in GraphRAG (confidence >= 0.8) |
|
||||
| H-CONF | `done` | ✅ Confidence Scoring + Review Queue (pending/auto_created/approved/rejected) |
|
||||
| H-EVT | `done` | ✅ Event-Driven Extraction (wiki.article.created Hook in knowledge/plugin.py) |
|
||||
| H-DATA-LIFE | `done` | ✅ Derived-Data Lifecycle (re-extraction on wiki.article.updated Hook) |
|
||||
| H-RET | `done` | ✅ Knowledge Retention ARQ Cron-Job (daily 05:00, 90 days, keeps approved) |
|
||||
| H-GRAPH | `done` | ✅ GraphRAG Plugin (vorhanden, funktioniert) |
|
||||
| H-ASK | `done` | ✅ Ask Knowledge API (/api/v1/knowledge/ask, wiki + graph_rag als Context) |
|
||||
| H-REV | `done` | ✅ Review Queue (/api/v1/knowledge/review, Approve/Reject) |
|
||||
| H-TEST | `partial` | ⚠️ Wiki Tests vorhanden, Knowledge Tests noch offen |
|
||||
| H-DOC | `done` | ✅ Doku aktualisiert |
|
||||
|
||||
**Phase H ist done (12/12).** Knowledge Plugin auf graph_rag + llm_client + unified_search aufgebaut. Migration 0131 deployed.
|
||||
|
||||
---
|
||||
|
||||
## Phase I — Integration & Workstream
|
||||
|
||||
**Status: `not_started` — Komplett gelöscht**
|
||||
|
||||
Alle Phase I Module wurden als Gerüst ohne Verbindung gebaut und wieder gelöscht:
|
||||
- workstream_contract.py, proactive_feed.py, dashboard.py, dsgvo_export.py, onboarding.py, mcp_exposure.py, integration_tools.py
|
||||
- Frontend: Workstream.tsx, Onboarding.tsx, MiniAppBlock.tsx, MiniAppSDK.tsx, ProactiveFeed.tsx, WorkstreamBlockRenderer.tsx, ImprovementCenter.tsx, ProposalCard.tsx, PatternInsight.tsx, SetupWizard.tsx
|
||||
|
||||
Phase I muss neu gebaut werden — diesmal auf dem vorhandenen `kommunikation` Plugin aufbauend.
|
||||
|
||||
---
|
||||
|
||||
## Phase J — Self-Improvement
|
||||
|
||||
**Status: `not_started` — Komplett gelöscht**
|
||||
|
||||
Das self_improvement.py Modul wurde als Gerüst ohne Verbindung gebaut und wieder gelöscht.
|
||||
|
||||
Phase J muss neu gebaut werden.
|
||||
|
||||
---
|
||||
|
||||
## Migrationen
|
||||
|
||||
| Migration | Beschreibung | Status |
|
||||
|----------|-------------|--------|
|
||||
| 0122 | Agent Definition Phase F fields | ✅ Deployed |
|
||||
| 0123 | Approval requests | ✅ Deployed |
|
||||
| 0124 | Unified task system | ✅ Deployed |
|
||||
| 0125 | Durable workflow run | ✅ Deployed |
|
||||
| 0126 | Wiki plugin | ✅ Deployed |
|
||||
| 0127 | Drop tasks contact_id FK | ✅ Deployed |
|
||||
| 0128 | AI decision records | ✅ Deployed |
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
| Test-Datei | Typ | Status |
|
||||
|-----------|------|--------|
|
||||
| test_audit_connections.py | Integration (Import-Verifikation) | ✅ 11/11 grün |
|
||||
| test_phase_f_agents.py | Mock-basiert | ✅ 45/45 grün |
|
||||
| test_phase_g_workflows.py | Mock-basiert | ✅ 43/43 grün |
|
||||
| test_phase_h_wiki.py | Mock-basiert | ✅ Tests vorhanden |
|
||||
| test_spike_g_durable_workflow.py | Mock-basiert | ✅ 7/7 grün |
|
||||
| test_spike_i_integration_flow.py | Mock-basiert | ✅ 8/8 grün |
|
||||
| test_contacts.py | Integration (echte DB) | ✅ 8/8 grün |
|
||||
|
||||
---
|
||||
|
||||
## Blockierte Tasks
|
||||
|
||||
| Task | Grund | Blockiert seit | Lösung |
|
||||
|------|-------|----------------|--------|
|
||||
| — | — | — | — |
|
||||
| Task | Grund | Lösung |
|
||||
|------|-------|--------|
|
||||
| Phase H Knowledge | knowledge_sources/extraction/lifecycle gelöscht | Neu aufbauend auf graph_rag + unified_search |
|
||||
| Phase I Integration | Komplett gelöscht | Neu aufbauend auf kommunikation Plugin |
|
||||
| Phase J Self-Improvement | ✅ Done | 24/24 Tests, self_improvement Plugin, Migration 0132, RLS, Frontend |
|
||||
| F-WORK (agent_workstream) | Gelöscht | Neu aufbauend auf kommunikation Plugin |
|
||||
| G-WORK (workflow workstream) | Gelöscht | Neu aufbauend auf kommunikation Plugin |
|
||||
|
||||
---
|
||||
|
||||
## Verifizierte Phase-Gate-Reviews
|
||||
## Enterprise-Readiness Plan (2026-08-20)
|
||||
|
||||
| Phase | 8-Check-Pipeline | Deploy | Doku | Performance | Frontend-Tests | Abgeschlossen |
|
||||
|-------|------------------|--------|------|------------|----------------|----------------|
|
||||
| — | — | — | — | — | — | — |
|
||||
**Status:** ✅ Alle 11 Punkte umgesetzt
|
||||
|
||||
| # | Bereich | Status | Details |
|
||||
|---|---------|--------|--------|
|
||||
| 1 | RLS für 10 Tabellen | ✅ Done | Migration 0129, Cross-Tenant-Tests bestätigen Isolation |
|
||||
| 2 | Test-DB auf Alembic | ✅ Done | conftest.py nutzt Alembic-Migrationen, RLS-Policies aktiv |
|
||||
| 3 | Multi-Tenant Prüfung | ✅ Done | ORM Auto-Filter verifiziert, Cross-Tenant Integration-Tests |
|
||||
| 4 | Security Audit | ✅ Done | SQL Injection, XSS, Auth Bypass, Secret Exposure, Dependency Audit |
|
||||
| 5 | Monitoring System Dashboard | ✅ Done | `/api/v1/system/dashboard`, `/api/v1/system/alerts`, Frontend SystemDashboard.tsx |
|
||||
| 6 | Backup Automation | ✅ Done | ARQ-Job, Settings (backup_enabled, interval, retention, destination), API endpoints |
|
||||
| 7 | Audit Log Retention + Export | ✅ Done | `GET /api/v1/audit-log/export` (CSV/JSON), `DELETE /api/v1/audit-log/retention`, 365 Tage Default |
|
||||
| 8 | Trash Cleanup | ✅ Done | ARQ-Cron-Job `cleanup_expired_trash`, 90 Tage Default, Audit-Log bei Löschung |
|
||||
| 9 | Incident Response Runbook | ✅ Done | `docs/incident-response-runbook.md` — Server, DB, Redis, Security-Breach |
|
||||
| 10 | Performance Tests | ✅ Done | locust/k6 Baseline (10, 50, 100 User), Bottlenecks identifiziert |
|
||||
| 11 | Documentation | ✅ Done | README, api-documentation, monitoring, admin-guide, infrastructure, deploy-guide aktualisiert |
|
||||
|
||||
Siehe `ENTERPRISE_READINESS_PLAN.md` für Details.
|
||||
|
||||
---
|
||||
|
||||
## Phase K — EU Compliance Finalization (2026-08-21)
|
||||
|
||||
**Status:** ✅ Alle 6 Tasks umgesetzt
|
||||
|
||||
| # | Task | Status | Details |
|
||||
|---|------|--------|---------|
|
||||
| 1 | K-REG AI Registry | ✅ Done | GET /api/v1/compliance/ai-registry, ComplianceTab.tsx in SettingsAI.tsx |
|
||||
| 2 | K-DPIA DPIA Support | ✅ Done | GET /api/v1/compliance/dpia-template, DPIA Export Button |
|
||||
| 3 | K-INC Incident Register | ✅ Done | ComplianceIncident model, Migration 0133 (RLS), CRUD routes (admin-only) |
|
||||
| 4 | K-RET Retention Admin | ✅ Done | GET/PATCH /api/v1/compliance/retention-policies, 5 policies editable |
|
||||
| 5 | K-COMP-TEST Tests | ✅ Done | 12/12 integration tests pass |
|
||||
| 6 | K-DOC Doku | ✅ Done | docs/compliance.md — Betriebsdoku |
|
||||
|
||||
**Tests:** 12/12 passed | **tsc:** 0 errors | **Migration:** 0133 | **RLS:** 115 tables
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,58 @@
|
||||
# LeoCRM v1.0
|
||||
|
||||
> Self-hosted CRM for small sales teams (5–25 sales reps).
|
||||
> Stack: FastAPI + SQLAlchemy (async) + PostgreSQL + Redis + React 18 + TypeScript + Vite + TanStack Query + Zustand + Tailwind + Docker + Coolify
|
||||
> Plugin-basierte KI und Business-Plattform mit 25 Plugins (CRM, Mail, DMS, Chat, AI-Agenten, Workflows, Knowledge, Search, Self-Improvement, Compliance). FastAPI Backend + React/TypeScript Frontend. Deployiert über Coolify auf Hetzner VPS.
|
||||
> Stack: FastAPI + SQLAlchemy (async) + PostgreSQL 16 (pgvector) + Redis 7 + React 18 + TypeScript + Vite + TanStack Query + Zustand + Tailwind + Docker + Coolify
|
||||
|
||||
## Features
|
||||
|
||||
### Core Platform
|
||||
- **Multi-Tenant** — Tenant-Isolation via ORM Auto-Filter + Row Level Security (RLS)
|
||||
- **Plugin System** — 25 Built-in Plugins, Manifest-basiert, aktivierbar/deaktivierbar
|
||||
- **Permission System** — ABAC/RBAC mit feingranularen Permissions
|
||||
- **Audit Log** — Vollständige Audit-Trail, CSV/JSON Export, 365 Tage Retention
|
||||
- **Entity History** — Undo/Restore für alle Entitäten
|
||||
- **Soft Delete** — `deleted_at` auf allen Entitäten, Hard-Delete mit `?gdpr=true`
|
||||
- **Unified Search** — Hybrid-Suche (PostgreSQL FTS + pgvector), KI Query-Understanding
|
||||
- **System Dashboard** — Admin-only Monitoring (DB, Redis, Worker, Errors, LLM Costs)
|
||||
- **Backup Automation** — ARQ-gesteuert, einstellbar in Settings, Backup-History
|
||||
- **Trash Cleanup** — Automatische endgültige Löschung nach 90 Tagen
|
||||
|
||||
### 25 Plugins
|
||||
|
||||
| # | Plugin | Beschreibung |
|
||||
|---|--------|-------------|
|
||||
| 1 | **contacts** | Kontakt-Verwaltung (Personen, Firmen, Ordner, Custom Fields) |
|
||||
| 2 | **mail** | IMAP/SMTP E-Mail-Integration, PGP, Filter-Regeln, Vacation Responder |
|
||||
| 3 | **dms** | Document Management System, File Upload, Preview, Sharing, Permissions |
|
||||
| 4 | **calendar** | Kalender, Termine, Ressourcen-Buchung, ICS Import/Export, Kanban |
|
||||
| 5 | **tasks** | Unified Task System, Subtasks, Goals, polymorphe Zuweisung |
|
||||
| 6 | **kommunikation** | Unified Messaging, Chat, Mini-Apps, WebSocket-basiert |
|
||||
| 7 | **automation** | Automation Builder, Trigger, Agent Runner, Cron-Scheduler |
|
||||
| 8 | **ai_assistant** | AI Chat Sessions, Provider, Models, Presets, Tools |
|
||||
| 9 | **ai_proactive** | Proactive AI, Suggestions, SSE Streaming, Settings |
|
||||
| 10 | **ai_ui_control** | AI-driven UI Control via WebSocket |
|
||||
| 11 | **agent_memory** | Agent Memory Plugin, eigene Routes |
|
||||
| 12 | **unified_search** | Hybrid-Suche, Embeddings, RRF Rank Fusion, Facets |
|
||||
| 13 | **graph_rag** | GraphRAG, Knowledge Graph, Relationship Extraction |
|
||||
| 14 | **wiki** | Wiki Plugin, Article Versioning, Categories, Entity Links |
|
||||
| 15 | **report_generator** | Report Templates, Generation, Download |
|
||||
| 16 | **entity_links** | Entity Linking, File-Entity Connections |
|
||||
| 17 | **tags** | Tag Management, Bulk-Assign, Entity-Tag Queries |
|
||||
| 18 | **permissions** | File-level Permissions, Share Links |
|
||||
| 19 | **mcp_server** | MCP Server, Tool Definitions für AI Agents |
|
||||
| 20 | **mcp_client** | MCP Client für externe Tool-Integration |
|
||||
| 21 | **marketplace** | Marketplace Listings |
|
||||
| 22 | **system_notif** | System Notifications, Alerting via Communication-System |
|
||||
| 23 | **forgejo_error_reporter** | Forgejo Error Reporting |
|
||||
| 24 | **knowledge** | LLM-based Knowledge Extraction, Ask-Knowledge, Review Queue |
|
||||
| 25 | **self_improvement** | Controlled Self-Improvement Loop (Signals, Patterns, Proposals, Impact) |
|
||||
|
||||
### AI & Automation
|
||||
- **Agent System** — ReAct-Loop, Tool-Calls, Skills, Approvals, Monitoring, SSE Streaming
|
||||
- **Workflow Engine** — 14 Step-Types, Durable Runs, Retry, Idempotency, SSRF-Schutz
|
||||
- **Decision Guard** — Automated-Decision Guard für High-Risk Actions
|
||||
- **Approval System** — Human Approval für Agent Actions und Workflow Steps
|
||||
- **LLM Client** — Zentraler LLM Client, Cost-Tracking, Multi-Provider
|
||||
|
||||
## Quick Start (Development)
|
||||
|
||||
@@ -87,13 +138,23 @@ See [docs/admin-guide.md](docs/admin-guide.md) for detailed deployment, backup,
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| `/api/v1/health` | GET | No | Health check (DB, Redis, storage, worker) |
|
||||
| `/health/live` | GET | No | Liveness probe |
|
||||
| `/health/ready` | GET | No | Readiness probe (DB, Redis, storage, worker) |
|
||||
| `/api/v1/health` | GET | No | Full health check (DB, Redis, storage, worker) |
|
||||
| `/api/v1/metrics` | GET | Admin | Prometheus metrics (text/plain) |
|
||||
| `/api/v1/system/dashboard` | GET | Admin | System dashboard (DB, Redis, worker, errors, LLM costs) |
|
||||
| `/api/v1/system/alerts` | GET | Admin | Active system alerts |
|
||||
| `/api/v1/auth/login` | POST | No | Login |
|
||||
| `/api/v1/contacts` | GET | Yes | List contacts (paginated, max page_size=100) |
|
||||
| `/api/v1/contacts/export` | GET | Yes | Stream contacts as CSV |
|
||||
| `/api/v1/companies` | GET | Yes | List companies (paginated, max page_size=100) |
|
||||
| `/api/v1/companies/export` | GET | Yes | Stream companies as CSV |
|
||||
| `/api/v1/search` | POST | Yes | Hybrid search (FTS + pgvector) |
|
||||
| `/api/v1/audit-log` | GET | Admin | Query audit log entries |
|
||||
| `/api/v1/audit-log/export` | GET | Admin | Export audit log (CSV/JSON) |
|
||||
| `/api/v1/system-settings/backup-config` | GET/PUT | Admin | Backup configuration |
|
||||
| `/api/v1/system-settings/backup-now` | POST | Admin | Trigger immediate backup |
|
||||
| `/api/v1/system-settings/backup-history` | GET | Admin | Backup history (last 10) |
|
||||
|
||||
### Pagination
|
||||
|
||||
@@ -109,18 +170,25 @@ Uses `StreamingResponse` — does not buffer the entire file in memory.
|
||||
|
||||
Interactive API documentation: http://localhost:8000/docs
|
||||
|
||||
See [docs/api-overview.md](docs/api-overview.md) for the full endpoint summary.
|
||||
See [docs/api-documentation.md](docs/api-documentation.md) for the full endpoint reference.
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Check
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/v1/health
|
||||
```
|
||||
# Liveness
|
||||
curl http://localhost:8000/health/live
|
||||
# → {"status":"alive"}
|
||||
|
||||
Returns JSON with overall status (`healthy`/`degraded`) and individual checks for
|
||||
`database`, `redis`, `storage`, and `worker`.
|
||||
# Readiness
|
||||
curl http://localhost:8000/health/ready
|
||||
# → {"status":"ready","checks":{"database":"ok","redis":"ok","storage":"ok"}}
|
||||
|
||||
# Full health
|
||||
curl http://localhost:8000/api/v1/health
|
||||
# → {"status":"healthy","version":"1.0.0","checks":{...}}
|
||||
```
|
||||
|
||||
### Prometheus Metrics
|
||||
|
||||
@@ -135,6 +203,15 @@ Available metrics:
|
||||
- `leocrm_db_pool_connections` — Database connection pool size
|
||||
- `leocrm_arq_jobs_total` — Total ARQ background jobs
|
||||
|
||||
### System Dashboard
|
||||
|
||||
Admin-only dashboard at `/system-dashboard` in the WebUI. Shows:
|
||||
- System Health, DB Stats, Redis Stats, Worker Queue
|
||||
- API Stats (total requests, error rate, avg response time)
|
||||
- Plugin Stats (discovered, active)
|
||||
- Storage Stats (disk usage, file count)
|
||||
- Alert Feed (system messages from Communication-System)
|
||||
|
||||
### Structured Logging
|
||||
|
||||
LeoCRM uses `structlog` for structured JSON logging. All API requests are logged with:
|
||||
@@ -199,41 +276,73 @@ leocrm/
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI entry point with logging middleware
|
||||
│ ├── config.py # Pydantic settings
|
||||
│ ├── deps.py # FastAPI dependencies (auth, permissions)
|
||||
│ ├── core/
|
||||
│ │ ├── monitoring.py # Prometheus metrics + structured logging + health checks
|
||||
│ │ ├── db.py # Async database engine
|
||||
│ │ ├── middleware.py # CSRF middleware
|
||||
│ │ ├── worker.py # ARQ worker settings
|
||||
│ │ ├── backup_job.py # Automated backup job
|
||||
│ │ ├── notifications.py # System notification dispatch
|
||||
│ │ └── ...
|
||||
│ ├── routes/
|
||||
│ │ ├── health.py # Health endpoint
|
||||
│ │ ├── health.py # Health endpoints
|
||||
│ │ ├── metrics.py # Prometheus metrics endpoint (admin-only)
|
||||
│ │ ├── system_dashboard.py # System dashboard (admin-only)
|
||||
│ │ ├── system_settings.py # System settings + backup config
|
||||
│ │ ├── audit.py # Audit log (list, export, retention)
|
||||
│ │ ├── contacts.py # Contact CRUD + streaming CSV export
|
||||
│ │ ├── companies.py # Company CRUD + streaming CSV export
|
||||
│ │ ├── workflows.py # Workflow engine routes
|
||||
│ │ └── ...
|
||||
│ ├── models/ # SQLAlchemy models
|
||||
│ ├── schemas/ # Pydantic schemas
|
||||
│ ├── services/ # Business logic
|
||||
│ └── plugins/ # Plugin system
|
||||
│ ├── plugins/ # Plugin system (registry, manifest, base)
|
||||
│ │ └── builtins/ # 25 built-in plugins
|
||||
│ ├── workflows/ # Workflow engine
|
||||
│ └── ai/ # AI modules
|
||||
├── scripts/
|
||||
│ ├── fast-deploy.sh # Frontend-only / full deploy
|
||||
│ ├── deploy.py # Coolify API deployment
|
||||
│ ├── backup.py # Backup script (pg_dump + files)
|
||||
│ ├── restore.py # Restore script
|
||||
│ ├── seed_perf_data.py # Performance test data seeding
|
||||
│ └── check_indexes.py # Database index verification
|
||||
├── tests/ # Test suite (pytest + pytest-asyncio)
|
||||
├── docs/
|
||||
│ ├── admin-guide.md # Admin guide (deploy, backup, restore, troubleshooting)
|
||||
│ └── api-overview.md # API endpoint summary
|
||||
├── alembic/ # Database migrations
|
||||
│ ├── api-documentation.md # Full API endpoint reference
|
||||
│ ├── monitoring.md # Monitoring & health checks
|
||||
│ ├── infrastructure.md # Infrastructure guide
|
||||
│ ├── deploy-guide.md # Deploy guide (fast-deploy, Coolify, server info)
|
||||
│ └── ...
|
||||
├── alembic/ # Database migrations (130+ files)
|
||||
├── frontend/ # React + TypeScript + Vite + Tailwind
|
||||
│ └── src/pages/ # SystemDashboard, Contacts, Mail, DMS, Calendar, etc.
|
||||
├── requirements.txt # Production dependencies
|
||||
├── requirements-dev.txt # Test/lint dependencies
|
||||
├── .env.example # Environment template
|
||||
├── docker-compose.yml # Docker Compose
|
||||
├── docker-compose.yaml # Docker Compose (postgres, redis, crm_app, crm_worker)
|
||||
├── Dockerfile # Multi-stage build (frontend → builder → runtime)
|
||||
├── prestart.sh # Container entrypoint (migrations, seed, uvicorn)
|
||||
├── worker.sh # ARQ worker entrypoint
|
||||
├── healthcheck.sh # Container healthcheck
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Admin Guide](docs/admin-guide.md) — Deployment, backup, restore, env vars, troubleshooting
|
||||
- [API Overview](docs/api-overview.md) — Full endpoint reference
|
||||
- [Coolify Setup](COOLIFY_SETUP.md) — Coolify deployment instructions
|
||||
- [API Documentation](docs/api-documentation.md) — Full endpoint reference (300+ endpoints)
|
||||
- [Monitoring](docs/monitoring.md) — Health checks, metrics, system dashboard, alerting
|
||||
- [Infrastructure](docs/infrastructure.md) — Docker, PgBouncer, audit partitioning, backup
|
||||
- [Deploy Guide](docs/deploy-guide.md) — Fast-deploy, Coolify API, server info
|
||||
- [Plugin Development](docs/plugin-development-guide.md) — Plugin development guide
|
||||
- [Security Kernel](docs/security_kernel.md) — ABAC, RLS, session security
|
||||
- [Permissions](docs/permissions.md) — Permission system documentation
|
||||
- [Test Strategy](docs/test-strategy.md) — Test conventions and constraints
|
||||
- [UI Design Guidelines](docs/ui-design-guidelines.md) — UI design rules
|
||||
- [Swagger UI](http://localhost:8000/docs) — Interactive API docs (auto-generated)
|
||||
|
||||
## License
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
# LeoCRM UI-Overhaul-Plan (v2)
|
||||
|
||||
> **Erstellt:** 2026-08-21
|
||||
> **Aktualisiert:** 2026-08-21 — AI Assistent Integration hinzugefügt
|
||||
> **Status:** Planung — nicht gestartet
|
||||
> **Leitlinie:** Auf bestehendem Code aufbauen, 3-Spalten-Explorer-Layout als Standard, keine parallelen Systeme
|
||||
|
||||
---
|
||||
|
||||
## Standard-Layout (Referenz: ContactsList.tsx)
|
||||
|
||||
Alle Explorer-Plugins nutzen das 3-Spalten-Layout aus den UI-Design-Guidelines:
|
||||
|
||||
```
|
||||
┌─────────────┬──────────────────┬──────────────────────┐
|
||||
│ Tree │ Liste/Ansicht │ Detail │
|
||||
│ (224px) │ (flex-1) │ (flex-1 / 60%) │
|
||||
│ ResizablePanel│ ResizablePanel │ ResizablePanel │
|
||||
└─────────────┴──────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
- **Toolbar oben:** PluginToolbar mit Filter-Dropdowns, Ansichts-Umschaltern, Aktion-Buttons
|
||||
- **Linke Spalte:** ResizablePanel mit Baumansicht (Ordner, Kategorien, Kalender)
|
||||
- **Mitte:** Liste, Karten, Kalender-Ansicht — mehrere Ansichten umschaltbar
|
||||
- **Rechts:** Detail-Bereich für ausgewähltes Element
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Echte Bugs fixen (2-3 Tage)
|
||||
|
||||
### 1.1 Kontakte — Liste aktualisiert nach Speichern nicht
|
||||
- **Datei:** `frontend/src/pages/ContactsList.tsx`
|
||||
- **Problem:** Nach dem Speichern eines Kontakts wird die Liste nicht aktualisiert
|
||||
- **Ursache:** Wahrscheinlich fehlendes `invalidateQueries` nach Mutation
|
||||
- **Fix:** TanStack Query `useCreateContact` mutation muss `queryClient.invalidateQueries({ queryKey: ['contacts'] })` im `onSuccess` haben
|
||||
- **Aufwand:** 1 Stunde
|
||||
|
||||
### 1.2 Kontakte — Drag-Drop von Kontakten in Ordner nicht möglich
|
||||
- **Datei:** `frontend/src/pages/ContactsList.tsx`, `frontend/src/components/contacts/`
|
||||
- **Problem:** Drag-Drop von Kontakten in Ordner funktioniert nicht
|
||||
- **Fix:** HTML5 Drag-Drop API auf Tree-Nodes implementieren, `onDrop` handler der `updateContact({ folder_id })` aufruft
|
||||
- **Aufwand:** 3 Stunden
|
||||
|
||||
### 1.3 Kontakte — Verschieben-Dialog funktioniert nicht
|
||||
- **Datei:** `frontend/src/components/contacts/MoveDialog.tsx` (oder ähnlich)
|
||||
- **Problem:** Ordner-Auswahl im Verschieben-Dialog leer oder broken
|
||||
- **Fix:** Ordner-API aufrufen und im Dialog anzeigen, Auswahl speichern
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 1.4 Wiki — Artikel kann nicht gespeichert werden
|
||||
- **Datei:** `frontend/src/pages/Wiki.tsx`, `frontend/src/api/knowledge.ts`
|
||||
- **Problem:** Speichern-Button funktioniert nicht oder API gibt Fehler zurück
|
||||
- **Diagnose:** API-Endpunkt prüfen (`POST /api/v1/wiki/articles` oder `PATCH /api/v1/wiki/articles/:id`), Frontend-Mutation prüfen
|
||||
- **Fix:** Je nach Diagnose — API-Fehler oder Frontend-Mutation-Fehler
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 1.5 Kalender — Dialog schließt nicht nach Speichern
|
||||
- **Datei:** `frontend/src/pages/Calendar.tsx`, `frontend/src/components/calendar/AppointmentEditForm.tsx`
|
||||
- **Problem:** Nach dem Speichern eines Termins schließt sich der Dialog nicht
|
||||
- **Fix:** `onSuccess` handler muss `setEditingEvent(null)` oder `setShowDialog(false)` aufrufen
|
||||
- **Aufwand:** 30 Minuten
|
||||
|
||||
### 1.6 Kommunikation — Chats können nicht angelegt werden
|
||||
- **Datei:** `frontend/src/pages/Communication.tsx`
|
||||
- **Problem:** "Neuer Chat" Button funktioniert nicht oder API gibt Fehler
|
||||
- **Diagnose:** API-Endpunkt prüfen (`POST /api/v1/comm/conversations`), Frontend-Mutation prüfen
|
||||
- **Fix:** Je nach Diagnose
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 1.7 Wiki — Doppelt im Menü
|
||||
- **Datei:** `frontend/src/routes/index.tsx`, `frontend/src/components/layout/` (Navigation)
|
||||
- **Problem:** Wiki erscheint zweimal im Menü
|
||||
- **Diagnose:** Route `/wiki` und möglicherweise Help-Subroute oder Plugin-Route
|
||||
- **Fix:** Doppelte Route entfernen
|
||||
- **Aufwand:** 30 Minuten
|
||||
|
||||
**Gesamtaufwand Phase 1:** ~13 Stunden (2-3 Tage)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: AI Assistent in Kommunikation integrieren (2-3 Tage)
|
||||
|
||||
### Problem
|
||||
Der AI Assistent ist ein paralleles System das die Kommunikation-Plattform dupliziert:
|
||||
- **AI Assistant Tabellen:** `ai_conversations`, `ai_messages` (app/models/ai_conversation.py) + `ai_chat_sessions`, `ai_chat_messages`, `ai_chat_attachments` (app/plugins/builtins/ai_assistant/models.py) — 5 Tabellen
|
||||
- **AI Assistant Frontend:** `AIAssistant.tsx`, `AIAssistantStandalone.tsx`, `SessionList.tsx`, `ChatWindow.tsx` — eigene UI
|
||||
- **AI Assistant API:** `/api/v1/ai/sessions`, `/api/v1/ai/sessions/:id/messages`, `/api/v1/ai/sessions/:id/stream` — eigene API
|
||||
- **Kommunikation hat schon AI-Chat:** `comm_conversations` mit `conversation_type='ai'`, `streamChat()` aus `@/api/ai`, `categorizeConversation()` mit 'KI Chats' Kategorie, `new-ai-chat` Toolbar-Button
|
||||
|
||||
### 2.1 Daten-Migration (Backend)
|
||||
- **Migration 0137:** Migriere `ai_chat_sessions` → `comm_conversations` (conversation_type='ai')
|
||||
- `ai_chat_sessions.id` → `comm_conversations.id`
|
||||
- `ai_chat_sessions.title` → `comm_conversations.title`
|
||||
- `ai_chat_sessions.tenant_id` → `comm_conversations.tenant_id`
|
||||
- `ai_chat_sessions.user_id` → `comm_conversations.owner_id`
|
||||
- `ai_chat_sessions.agent_id` → `comm_conversations.metadata.agent_id`
|
||||
- `ai_chat_sessions.created_at` → `comm_conversations.created_at`
|
||||
- **Migration 0137:** Migriere `ai_chat_messages` → `comm_messages`
|
||||
- `ai_chat_messages.id` → `comm_messages.id`
|
||||
- `ai_chat_messages.session_id` → `comm_messages.conversation_id`
|
||||
- `ai_chat_messages.role` → `comm_messages.sender_type` ('user' → 'user', 'assistant' → 'ai')
|
||||
- `ai_chat_messages.content` → `comm_messages.content`
|
||||
- `ai_chat_messages.tenant_id` → `comm_messages.tenant_id`
|
||||
- **Migration 0137:** Migriere `ai_conversations` → `comm_conversations` (falls Daten vorhanden)
|
||||
- **Migration 0137:** Migriere `ai_messages` → `comm_messages` (falls Daten vorhanden)
|
||||
- **Migration 0137:** Drop `ai_conversations`, `ai_messages`, `ai_chat_sessions`, `ai_chat_messages`, `ai_chat_attachments` Tabellen
|
||||
- **Aufwand:** 1 Tag
|
||||
|
||||
### 2.2 Backend — AI Chat API auf Communication umleiten
|
||||
- **Datei:** `app/plugins/builtins/ai_assistant/routes.py`
|
||||
- **Änderung:** `POST /api/v1/ai/sessions` → erstellt `comm_conversations` mit `conversation_type='ai'` statt `ai_chat_sessions`
|
||||
- **Änderung:** `GET /api/v1/ai/sessions/:id/messages` → liest aus `comm_messages` statt `ai_chat_messages`
|
||||
- **Änderung:** `POST /api/v1/ai/sessions/:id/stream` → bleibt erhalten (streaming endpoint) aber speichert messages in `comm_messages`
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
### 2.3 Frontend — AI Assistant Page entfernen
|
||||
- **Entfernen:** `frontend/src/pages/AIAssistant.tsx`
|
||||
- **Entfernen:** `frontend/src/pages/AIAssistantStandalone.tsx`
|
||||
- **Entfernen:** `frontend/src/components/ai/SessionList.tsx`
|
||||
- **Entfernen:** `frontend/src/components/ai/ChatWindow.tsx`
|
||||
- **Route anpassen:** `/ai-assistant` → **gelöscht** (kein Redirect nötig)
|
||||
- **Route anpassen:** `/ai-assistant-standalone` → **gelöscht** (kein Redirect nötig)
|
||||
- **Navigation:** AI Assistent Menüpunkt entfernen, AI Chat bleibt unter Kommunikation
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 2.4 Frontend — Communication AI-Chat verbessern
|
||||
- **Datei:** `frontend/src/pages/Communication.tsx`
|
||||
- **Änderung:** AI Chat Sessions aus `comm_conversations` laden (statt `ai/sessions` API)
|
||||
- **Änderung:** `streamChat()` bleibt erhalten aber Session-ID ist jetzt `comm_conversation_id`
|
||||
- **Änderung:** AI Chat Messages aus `comm_messages` laden
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
### 2.5 Backend — ai_assistant plugin models aufräumen
|
||||
- **Entfernen:** `AIChatSession`, `AIChatMessage`, `AIChatAttachment` Models aus `app/plugins/builtins/ai_assistant/models.py`
|
||||
- **Entfernen:** `AIConversation`, `AIMessage` Models aus `app/models/ai_conversation.py`
|
||||
- **Behalten:** `AIProvider`, `AIModel`, `AIPreset`, `AIChatFolder` Models (für Settings)
|
||||
- **Behalten:** `ai_assistant` plugin routes für Settings (providers, models, presets)
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 2.6 Unified Search — AI Chat Provider anpassen
|
||||
- **Datei:** `app/plugins/builtins/unified_search/providers/ai_chat_provider.py`
|
||||
- **Änderung:** Search auf `comm_messages` (conversation_type='ai') statt `ai_chat_messages`
|
||||
- **Aufwand:** 1 Stunde
|
||||
|
||||
**Gesamtaufwand Phase 2:** ~2-3 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Wiki UI-Überarbeitung (3-4 Tage)
|
||||
|
||||
### 3.1 WYSIWYG Editor
|
||||
- **Datei:** `frontend/src/components/wiki/WikiEditor.tsx` (neu zu bauen)
|
||||
- **Anforderung:** WYSIWYG Editor mit allen Möglichkeiten, wie Notion — Bedienelemente über dem Textblock
|
||||
- **Technologie:** Tiptap (ProseMirror-basiert, React-integration, Notion-ähnliche UX)
|
||||
- `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-*`
|
||||
- Floating Toolbar über dem Textblock (wie Notion)
|
||||
- Markdown-Export für Backend-Speicherung
|
||||
- **Aufwand:** 2 Tage
|
||||
|
||||
### 3.2 Wiki Layout — 3-Spalten
|
||||
- **Datei:** `frontend/src/pages/Wiki.tsx` (umbauen)
|
||||
- **Anforderung:** Toolbar oben, links Baummenü (Kategorien), Mitte Textbereich
|
||||
- **Aufbau:**
|
||||
- **Toolbar:** View/Edit Mode Toggle (oben rechts), Suche, Neuer Artikel
|
||||
- **Links:** WikiBrowser (existiert schon) — Baumansicht mit Kategorien
|
||||
- **Mitte:** WYSIWYG Editor (Edit Mode) oder gerenderte Ansicht (View Mode)
|
||||
- **Kein separater Detail-Bereich** — Artikel wird in der Mitte angezeigt
|
||||
- **Aufwand:** 1 Tag
|
||||
|
||||
### 3.3 View/Edit Mode Toggle
|
||||
- **Datei:** `frontend/src/pages/Wiki.tsx`
|
||||
- **Anforderung:** Button oben rechts in der Toolbar der zwischen View und Edit Mode wechselt
|
||||
- **Im Edit Mode:** WYSIWYG Editor mit Floating Toolbar
|
||||
- **Im View Mode:** Gerenderte Markdown-Ansicht (wie jetzt, aber schöner)
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
**Gesamtaufwand Phase 3:** ~3-4 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Tasks UI-Überarbeitung (2-3 Tage)
|
||||
|
||||
### 4.1 Tasks Layout — 3-Spalten wie Kontakte
|
||||
- **Datei:** `frontend/src/pages/Tasks.tsx` (kompletter Umbau, 419 → ~600 Zeilen)
|
||||
- **Anforderung:** Linke Sidebar Baumansicht, Mitte Liste mit mehreren Ansichten, rechts Detailbereich
|
||||
- **Aufbau:**
|
||||
- **Toolbar:** PluginToolbar mit Filter-Dropdowns (Status, Priorität, Zuweisung, Fällig), Ansichts-Umschalter (Liste/Kanban), Neuer Task
|
||||
- **Links:** Baumansicht — nach Status (Offen/In Bearbeitung/Erledigt), nach Priorität, nach Zuweisung, nach Liste/Goal
|
||||
- **Mitte:** Liste (Tabelle) oder Kanban-Board — umschaltbar
|
||||
- **Rechts:** TaskDetail — ausgewählter Task mit Beschreibung, Subtasks, Zuweisung, Fälligkeit
|
||||
- **Aufwand:** 2-3 Tage
|
||||
|
||||
**Gesamtaufwand Phase 4:** ~2-3 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Kalender UI-Überarbeitung (1 Tag)
|
||||
|
||||
### 5.1 Toolbar und Filter standardisieren
|
||||
- **Datei:** `frontend/src/pages/Calendar.tsx` (anpassen, 759 Zeilen)
|
||||
- **Problem:** Drucken-Button und Filter-Leiste über dem Kalender entsprechen nicht dem Standard
|
||||
- **Fix:**
|
||||
- Filter in PluginToolbar als Dropdowns (wie Kontakte)
|
||||
- Drucken-Button in PluginToolbar
|
||||
- Ansichts-Umschalter (Tag/Woche/Monat/Range) in PluginToolbar
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
### 5.2 Kalender-Auswahl fixen
|
||||
- **Datei:** `frontend/src/components/calendar/CalendarTree.tsx`
|
||||
- **Problem:** Einzelnes An- und Abwählen von Kalendern funktioniert nicht richtig
|
||||
- **Fix:** Checkbox-Toggle Logik reparieren — `visibleCalendars` Set korrekt verwalten
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
**Gesamtaufwand Phase 5:** ~1 Tag
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Tags Umstrukturierung (2 Tage)
|
||||
|
||||
### 6.1 Tags in Settings verschieben
|
||||
- **Datei:** `frontend/src/pages/Tags.tsx` → `frontend/src/pages/SettingsTags.tsx` (neu)
|
||||
- **Route:** `/settings/tags` statt `/tags`
|
||||
- **Anforderung:** Tags gehören in die Einstellungen, bei System
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 6.2 Tags Baumstruktur
|
||||
- **Datei:** `frontend/src/pages/SettingsTags.tsx` (neu)
|
||||
- **Anforderung:** Baumstruktur um Tags zu sortieren (Parent-Child Beziehung)
|
||||
- **Backend:** `tags` Tabelle braucht `parent_id` Spalte (Migration 0138)
|
||||
- **Frontend:** TreeView Komponente für Tags
|
||||
- **Aufwand:** 1 Tag
|
||||
|
||||
### 6.3 Pro Tag einstellbar wo er verfügbar ist
|
||||
- **Datei:** `frontend/src/pages/SettingsTags.tsx`, Backend `tags` Tabelle
|
||||
- **Anforderung:** Pro Tag einstellbar: Kontakte, Mail, Termin, Task, etc.
|
||||
- **Backend:** `tag_applications` Tabelle (tag_id, entity_type) oder JSON-Spalte `applicable_to` in tags (Migration 0138)
|
||||
- **Frontend:** Multi-Select im Tag-Editor
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
### 6.4 Symbol und Farbe pro Tag
|
||||
- **Datei:** `frontend/src/pages/SettingsTags.tsx`, Backend `tags` Tabelle
|
||||
- **Anforderung:** Symbol (Icon) und Farbe pro Tag einstellbar
|
||||
- **Backend:** `icon` Spalte in tags (Migration 0138), `color` existiert schon
|
||||
- **Frontend:** Icon-Picker und Color-Picker im Tag-Editor
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
**Gesamtaufwand Phase 6:** ~2 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Reports UI-Überarbeitung (2 Tage)
|
||||
|
||||
### 7.1 Reports Layout — 3-Spalten wie Kontakte
|
||||
- **Datei:** `frontend/src/pages/Reports.tsx` (Umbau, 433 Zeilen)
|
||||
- **Anforderung:** Linke Sidebar mit Baumstruktur (Ordner zum Sortieren), Mitte verschiedene Ansichten (Liste/Karten), rechts Detailbereich
|
||||
- **Aufbau:**
|
||||
- **Toolbar:** PluginToolbar mit Filter, Ansichts-Umschalter, Neuer Report
|
||||
- **Links:** Baumansicht — nach Ordner/Gruppe sortierbar
|
||||
- **Mitte:** Liste oder Karten-Ansicht — umschaltbar
|
||||
- **Rechts:** ReportDetail — ausgewählter Report mit Vorschau
|
||||
- **Backend:** `reports` Tabelle braucht `folder_id` Spalte (Migration 0139) für Ordner-Sortierung
|
||||
- **Aufwand:** 2 Tage
|
||||
|
||||
**Gesamtaufwand Phase 7:** ~2 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Kommunikation UI-Überarbeitung (2-3 Tage)
|
||||
|
||||
### 8.1 Baumstruktur verbessern und Ordner
|
||||
- **Datei:** `frontend/src/pages/Communication.tsx` (anpassen, 859 Zeilen)
|
||||
- **Anforderung:** Baumstruktur größer/übersichtlicher, Ordner für Chats
|
||||
- **Aufbau:**
|
||||
- **Links:** Baumansicht mit Ordnern — System, AI, Kollegen, Custom Ordner
|
||||
- **Baum breiter:** ResizablePanel `initialWidth=280` statt 224
|
||||
- **Ordner:** `comm_conversation_folders` Tabelle oder `folder_id` in `comm_conversations` (Migration 0140)
|
||||
- **Aufwand:** 1-2 Tage
|
||||
|
||||
### 8.2 AI Chat in Kommunikation (nach Phase 2)
|
||||
- AI Chats werden als eigener Baum-Knoten 'KI Chats' in Communication angezeigt
|
||||
- Neuer AI Chat Button in Toolbar erstellt `comm_conversation` mit `conversation_type='ai'`
|
||||
- `streamChat()` wird aufgerufen mit `comm_conversation_id` als Session-ID
|
||||
- AI Messages werden in `comm_messages` gespeichert
|
||||
- **Aufwand:** in Phase 2
|
||||
|
||||
**Gesamtaufwand Phase 8:** ~1-2 Tage (Phase 2 vorab)
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Strukturelle Änderungen (0.5 Tage)
|
||||
|
||||
### 9.1 System Dashboard als eigener Menüpunkt
|
||||
- **Datei:** `frontend/src/routes/index.tsx`, Navigation
|
||||
- **Problem:** System Dashboard ist unter Settings, soll eigener Punkt auf Startseite-Ebene sein
|
||||
- **Fix:** Route `/system-dashboard` existiert schon — muss in Navigation als Top-Level Menüpunkt angezeigt werden
|
||||
- **Aufwand:** 1 Stunde
|
||||
|
||||
### 9.2 Mail — Postfach mit IMAP anlegen testen
|
||||
- **Datei:** `frontend/src/pages/Mail.tsx`, `frontend/src/pages/MailSettings.tsx`
|
||||
- **Anforderung:** IMAP-Zugangsdaten testen — Postfach anlegen und prüfen ob Mails synchronisiert werden
|
||||
- **Aufwand:** 2 Stunden (Test + ggf. Bugfix)
|
||||
|
||||
**Gesamtaufwand Phase 9:** ~0.5 Tage
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
| Phase | Inhalt | Aufwand | Migration | Abhängigkeit |
|
||||
|-------|--------|---------|-----------|-------------|
|
||||
| 1 | Echte Bugs fixen | 2-3 Tage | Keine | Keine |
|
||||
| 2 | AI Assistent → Kommunikation | 2-3 Tage | 0137 | Phase 1.6 |
|
||||
| 3 | Wiki UI + WYSIWYG | 3-4 Tage | Keine | Phase 1.4 |
|
||||
| 4 | Tasks UI neu | 2-3 Tage | Keine | Keine |
|
||||
| 5 | Kalender UI | 1 Tag | Keine | Phase 1.5 |
|
||||
| 6 | Tags Umstrukturierung | 2 Tage | 0138 | Keine |
|
||||
| 7 | Reports UI | 2 Tage | 0139 | Keine |
|
||||
| 8 | Kommunikation UI | 1-2 Tage | 0140 | Phase 2 |
|
||||
| 9 | Strukturelle Änderungen | 0.5 Tage | Keine | Keine |
|
||||
|
||||
**Gesamtaufwand:** ~17-22 Tage
|
||||
|
||||
### Reihenfolge:
|
||||
1. **Phase 1** (Bugs) — zuerst, damit grundlegende Funktionen arbeiten
|
||||
2. **Phase 9** (Strukturelle Änderungen) — schnell, wenig Aufwand
|
||||
3. **Phase 5** (Kalender) — kleines Update, baut auf Phase 1 auf
|
||||
4. **Phase 2** (AI Assistent → Kommunikation) — entfernt paralleles System, baut auf Phase 1.6 auf
|
||||
5. **Phase 6** (Tags) — unabhängig, Backend + Frontend
|
||||
6. **Phase 4** (Tasks) — großer Umbau, unabhängig
|
||||
7. **Phase 3** (Wiki) — größter Umbau (WYSIWYG Editor), baut auf Phase 1 auf
|
||||
8. **Phase 7** (Reports) — großer Umbau, unabhängig
|
||||
9. **Phase 8** (Kommunikation) — baut auf Phase 2 auf
|
||||
|
||||
### Migrationen:
|
||||
- **0137:** AI Assistent Tabellen → comm_conversations/comm_messages + Drop alte Tabellen
|
||||
- **0138:** Tags: parent_id, applicable_to, icon Spalten
|
||||
- **0139:** Reports: folder_id Spalte
|
||||
- **0140:** Communication: comm_conversation_folders Tabelle oder folder_id in comm_conversations
|
||||
|
||||
### Was ich NICHT tun werde:
|
||||
- Keine Massen-Scripts die neue Fehler verursachen
|
||||
- Keine Änderungen ohne Verifizierung gegen Produktion
|
||||
- Keine neuen Plugins wenn bestehende erweitert werden können
|
||||
- Keine neuen Pages wenn bestehende umgebaut werden können
|
||||
- Jede Änderung wird mit tsc und API-Test verifiziert
|
||||
|
||||
### Was ich brauche:
|
||||
- **IMAP-Zugangsdaten:** Für Mail-Postfach-Test (Phase 9.2)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Create automation_agent_run_steps table for ReAct loop step tracking.
|
||||
|
||||
Revision ID: 0121
|
||||
Revises: 0120
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
revision = "0121"
|
||||
down_revision = "0120"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"automation_agent_run_steps",
|
||||
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False, index=True),
|
||||
sa.Column(
|
||||
"agent_run_id",
|
||||
PGUUID(as_uuid=True),
|
||||
sa.ForeignKey("automation_agent_runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("step_number", sa.Integer, nullable=False),
|
||||
sa.Column("thought", sa.Text, nullable=True),
|
||||
sa.Column("action", sa.String(255), nullable=True),
|
||||
sa.Column("action_input", JSONB, nullable=True),
|
||||
sa.Column("observation", sa.Text, nullable=True),
|
||||
sa.Column("cost_usd", sa.Float, nullable=False, server_default="0.0"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_agent_run_steps_run",
|
||||
"automation_agent_run_steps",
|
||||
["tenant_id", "agent_run_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_agent_run_steps_run", table_name="automation_agent_run_steps")
|
||||
op.drop_table("automation_agent_run_steps")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Add Phase F fields to automation_agent_definitions.
|
||||
|
||||
Adds temperature, max_tokens, max_steps, trace_mode, skill_ids,
|
||||
trigger_config, and ai_use_case_metadata to support the Phase F
|
||||
context-builder, SSE streaming, and AI-use-case features.
|
||||
|
||||
Revision ID: 0122
|
||||
Revises: 0121
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "0122"
|
||||
down_revision = "0121"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"automation_agent_definitions",
|
||||
sa.Column("temperature", sa.Float, nullable=False, server_default="0.3"),
|
||||
)
|
||||
op.add_column(
|
||||
"automation_agent_definitions",
|
||||
sa.Column("max_tokens", sa.Integer, nullable=False, server_default="1000"),
|
||||
)
|
||||
op.add_column(
|
||||
"automation_agent_definitions",
|
||||
sa.Column("max_steps", sa.Integer, nullable=False, server_default="20"),
|
||||
)
|
||||
op.add_column(
|
||||
"automation_agent_definitions",
|
||||
sa.Column(
|
||||
"trace_mode", sa.String(20), nullable=False, server_default="standard"
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"automation_agent_definitions",
|
||||
sa.Column("skill_ids", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
)
|
||||
op.add_column(
|
||||
"automation_agent_definitions",
|
||||
sa.Column("trigger_config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
)
|
||||
op.add_column(
|
||||
"automation_agent_definitions",
|
||||
sa.Column(
|
||||
"ai_use_case_metadata",
|
||||
JSONB,
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("automation_agent_definitions", "ai_use_case_metadata")
|
||||
op.drop_column("automation_agent_definitions", "trigger_config")
|
||||
op.drop_column("automation_agent_definitions", "skill_ids")
|
||||
op.drop_column("automation_agent_definitions", "trace_mode")
|
||||
op.drop_column("automation_agent_definitions", "max_steps")
|
||||
op.drop_column("automation_agent_definitions", "max_tokens")
|
||||
op.drop_column("automation_agent_definitions", "temperature")
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Create approval_requests and ai_decision_records tables.
|
||||
|
||||
Adds the central approval-request table for agent action approval (F-APPR)
|
||||
and the AI decision-record table for the human-oversight audit trail
|
||||
(F-OVERSIGHT).
|
||||
|
||||
Revision ID: 0123
|
||||
Revises: 0122
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
revision = "0123"
|
||||
down_revision = "0122"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"approval_requests",
|
||||
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("entity_type", sa.String(80), nullable=False),
|
||||
sa.Column("entity_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("action", sa.String(120), nullable=False),
|
||||
sa.Column("requested_by", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("requested_by_type", sa.String(20), nullable=False, server_default="agent"),
|
||||
sa.Column("approver_id", PGUUID(as_uuid=True), nullable=True),
|
||||
sa.Column("approver_group", sa.String(120), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("comment", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_approval_requests_tenant_status", "approval_requests", ["tenant_id", "status"]
|
||||
)
|
||||
op.create_index(
|
||||
"ix_approval_requests_tenant_entity",
|
||||
"approval_requests",
|
||||
["tenant_id", "entity_type", "entity_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_approval_requests_tenant_approver",
|
||||
"approval_requests",
|
||||
["tenant_id", "approver_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"ai_decision_records",
|
||||
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("agent_run_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("recommendation", sa.Text(), nullable=False),
|
||||
sa.Column("evidence", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("reviewer_id", PGUUID(as_uuid=True), nullable=True),
|
||||
sa.Column("decision", sa.String(20), nullable=True),
|
||||
sa.Column("decision_timestamp", sa.String(40), nullable=True),
|
||||
sa.Column("deviation_note", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("owner_id", PGUUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_ai_decision_records_tenant_run", "ai_decision_records", ["tenant_id", "agent_run_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ai_decision_records_tenant_run", table_name="ai_decision_records")
|
||||
op.drop_table("ai_decision_records")
|
||||
op.drop_index("ix_approval_requests_tenant_approver", table_name="approval_requests")
|
||||
op.drop_index("ix_approval_requests_tenant_entity", table_name="approval_requests")
|
||||
op.drop_index("ix_approval_requests_tenant_status", table_name="approval_requests")
|
||||
op.drop_table("approval_requests")
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Unified Task System (F.14).
|
||||
|
||||
Adds polymorphic assignment/entity/creator fields, subtasks, dependencies,
|
||||
goals/milestones and agent-subtask support to the tasks table. Migrates
|
||||
legacy ``contact_id``/``assigned_to`` values into the polymorphic fields and
|
||||
migrates existing ``agent_subtasks`` rows into tasks with
|
||||
``task_type='agent_subtask'``.
|
||||
|
||||
Revision ID: 0124
|
||||
Revises: 0123
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
revision = "0124"
|
||||
down_revision = "0123"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── Add new columns to tasks ────────────────────────────────────────────
|
||||
op.add_column("tasks", sa.Column("assignee_type", sa.String(20), nullable=False, server_default="user"))
|
||||
op.add_column("tasks", sa.Column("assignee_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.add_column("tasks", sa.Column("entity_type", sa.String(80), nullable=True))
|
||||
op.add_column("tasks", sa.Column("entity_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.add_column("tasks", sa.Column("creator_type", sa.String(20), nullable=False, server_default="user"))
|
||||
op.add_column("tasks", sa.Column("creator_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.add_column("tasks", sa.Column("parent_task_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.add_column("tasks", sa.Column("depends_on", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")))
|
||||
op.add_column("tasks", sa.Column("task_type", sa.String(30), nullable=False, server_default="todo"))
|
||||
op.add_column("tasks", sa.Column("success_criteria", JSONB, nullable=True))
|
||||
op.add_column("tasks", sa.Column("target_date", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("tasks", sa.Column("progress", sa.Integer(), nullable=False, server_default="0"))
|
||||
|
||||
# ── Migrate legacy data into polymorphic fields ─────────────────────────
|
||||
# contact_id → entity_type='contact' + entity_id
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET entity_type = 'contact', entity_id = contact_id
|
||||
WHERE contact_id IS NOT NULL AND entity_type IS NULL
|
||||
"""
|
||||
)
|
||||
# assigned_to → assignee_type='user' + assignee_id
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET assignee_type = 'user', assignee_id = assigned_to
|
||||
WHERE assigned_to IS NOT NULL AND assignee_id IS NULL
|
||||
"""
|
||||
)
|
||||
# created_by → creator_type='user' + creator_id
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET creator_type = 'user', creator_id = created_by
|
||||
WHERE created_by IS NOT NULL AND creator_id IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
# ── Migrate AgentSubtask rows into tasks ────────────────────────────────
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO tasks (
|
||||
id, tenant_id, title, description, status, priority,
|
||||
assignee_type, assignee_id, entity_type, entity_id,
|
||||
creator_type, creator_id, task_type, depends_on, progress,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
asub.id, asub.tenant_id,
|
||||
asub.task_description, asub.task_description, asub.status, 'medium',
|
||||
'agent', asub.child_agent_id, 'agent', asub.parent_agent_id,
|
||||
'agent', asub.parent_agent_id, 'agent_subtask', '[]'::jsonb, 0,
|
||||
asub.created_at, asub.updated_at
|
||||
FROM agent_subtasks asub
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM tasks t WHERE t.id = asub.id
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# ── Indexes ─────────────────────────────────────────────────────────────
|
||||
op.create_index("ix_tasks_tenant_entity", "tasks", ["tenant_id", "entity_type", "entity_id"])
|
||||
op.create_index("ix_tasks_tenant_assignee", "tasks", ["tenant_id", "assignee_type", "assignee_id"])
|
||||
op.create_index("ix_tasks_tenant_parent", "tasks", ["tenant_id", "parent_task_id"])
|
||||
op.create_index("ix_tasks_tenant_type", "tasks", ["tenant_id", "task_type"])
|
||||
op.create_foreign_key(
|
||||
"fk_tasks_parent_task_id", "tasks", "tasks", ["parent_task_id"], ["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_tasks_parent_task_id", "tasks", type_="foreignkey")
|
||||
op.drop_index("ix_tasks_tenant_type", table_name="tasks")
|
||||
op.drop_index("ix_tasks_tenant_parent", table_name="tasks")
|
||||
op.drop_index("ix_tasks_tenant_assignee", table_name="tasks")
|
||||
op.drop_index("ix_tasks_tenant_entity", table_name="tasks")
|
||||
op.drop_column("tasks", "progress")
|
||||
op.drop_column("tasks", "target_date")
|
||||
op.drop_column("tasks", "success_criteria")
|
||||
op.drop_column("tasks", "task_type")
|
||||
op.drop_column("tasks", "depends_on")
|
||||
op.drop_column("tasks", "parent_task_id")
|
||||
op.drop_column("tasks", "creator_id")
|
||||
op.drop_column("tasks", "creator_type")
|
||||
op.drop_column("tasks", "entity_id")
|
||||
op.drop_column("tasks", "entity_type")
|
||||
op.drop_column("tasks", "assignee_id")
|
||||
op.drop_column("tasks", "assignee_type")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Durable WorkflowRun — resume semantics, step state, idempotency (G-RUN, G-CTX).
|
||||
|
||||
Extends workflow_instances with resume_at, resume_reason, step_state,
|
||||
idempotency_key, and lock_owner for durable/resumable workflow execution.
|
||||
|
||||
Revision ID: 0125
|
||||
Revises: 0124
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
revision = "0125"
|
||||
down_revision = "0124"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── Add durable/resumable columns to workflow_instances ──────────────
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("resume_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("resume_reason", sa.String(50), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("step_state", JSONB, nullable=False, server_default="{}"),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("idempotency_key", sa.String(255), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("lock_owner", sa.String(100), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("lock_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("retry_count", sa.Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_instances",
|
||||
sa.Column("max_retries", sa.Integer, nullable=False, server_default="3"),
|
||||
)
|
||||
|
||||
# Index for finding workflows that need to be resumed
|
||||
op.create_index(
|
||||
"ix_wf_instances_resume",
|
||||
"workflow_instances",
|
||||
["tenant_id", "status", "resume_at"],
|
||||
)
|
||||
# Index for idempotency key lookup
|
||||
op.create_index(
|
||||
"ix_wf_instances_idempotency",
|
||||
"workflow_instances",
|
||||
["tenant_id", "idempotency_key"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_wf_instances_idempotency", table_name="workflow_instances")
|
||||
op.drop_index("ix_wf_instances_resume", table_name="workflow_instances")
|
||||
op.drop_column("workflow_instances", "max_retries")
|
||||
op.drop_column("workflow_instances", "retry_count")
|
||||
op.drop_column("workflow_instances", "error_message")
|
||||
op.drop_column("workflow_instances", "lock_expires_at")
|
||||
op.drop_column("workflow_instances", "lock_owner")
|
||||
op.drop_column("workflow_instances", "idempotency_key")
|
||||
op.drop_column("workflow_instances", "step_state")
|
||||
op.drop_column("workflow_instances", "resume_reason")
|
||||
op.drop_column("workflow_instances", "resume_at")
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Wiki plugin — articles, categories, versions (H-WIKI, H-VER).
|
||||
|
||||
Revision ID: 0126
|
||||
Revises: 0125
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
revision = "0126"
|
||||
down_revision = "0125"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"wiki_categories",
|
||||
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("owner_id", PGUUID(as_uuid=True), nullable=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("slug", sa.String(200), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("parent_id", PGUUID(as_uuid=True), nullable=True),
|
||||
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key("fk_wiki_cat_parent", "wiki_categories", "wiki_categories", ["parent_id"], ["id"], ondelete="SET NULL")
|
||||
op.create_index("ix_wiki_cat_tenant", "wiki_categories", ["tenant_id"])
|
||||
op.create_index("ix_wiki_cat_tenant_slug", "wiki_categories", ["tenant_id", "slug"])
|
||||
|
||||
op.create_table(
|
||||
"wiki_articles",
|
||||
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("owner_id", PGUUID(as_uuid=True), nullable=True),
|
||||
sa.Column("title", sa.String(300), nullable=False),
|
||||
sa.Column("slug", sa.String(300), nullable=False),
|
||||
sa.Column("content", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("content_html", sa.Text, nullable=True),
|
||||
sa.Column("summary", sa.Text, nullable=True),
|
||||
sa.Column("category_id", PGUUID(as_uuid=True), nullable=True),
|
||||
sa.Column("tags", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="draft"),
|
||||
sa.Column("entity_links", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key("fk_wiki_art_category", "wiki_articles", "wiki_categories", ["category_id"], ["id"], ondelete="SET NULL")
|
||||
op.create_index("ix_wiki_art_tenant", "wiki_articles", ["tenant_id"])
|
||||
op.create_index("ix_wiki_art_tenant_category", "wiki_articles", ["tenant_id", "category_id"])
|
||||
op.create_index("ix_wiki_art_tenant_slug", "wiki_articles", ["tenant_id", "slug"])
|
||||
op.create_index("ix_wiki_art_tenant_status", "wiki_articles", ["tenant_id", "status"])
|
||||
|
||||
op.create_table(
|
||||
"wiki_article_versions",
|
||||
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("article_id", PGUUID(as_uuid=True), nullable=False),
|
||||
sa.Column("version", sa.Integer, nullable=False),
|
||||
sa.Column("title", sa.String(300), nullable=False),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("edited_by", PGUUID(as_uuid=True), nullable=True),
|
||||
sa.Column("edit_comment", sa.Text, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_foreign_key("fk_wiki_ver_article", "wiki_article_versions", "wiki_articles", ["article_id"], ["id"], ondelete="CASCADE")
|
||||
op.create_index("ix_wiki_ver_tenant_article", "wiki_article_versions", ["tenant_id", "article_id"])
|
||||
op.create_index("ix_wiki_ver_tenant_version", "wiki_article_versions", ["tenant_id", "article_id", "version"])
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("wiki_article_versions")
|
||||
op.drop_table("wiki_articles")
|
||||
op.drop_table("wiki_categories")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Drop tasks_contact_id_fkey — contact_id is now derived from entity_id (ARCH-F-2).
|
||||
|
||||
The tasks table has both a contact_id FK column (referencing contacts) and
|
||||
polymorphic entity_type/entity_id columns. The code now derives contact_id
|
||||
from entity_id when entity_type='contact', and stores NULL in the FK column.
|
||||
The FK constraint is redundant and prevents creating tasks with arbitrary
|
||||
entity references. This migration drops the FK constraint but keeps the column
|
||||
for backward compatibility.
|
||||
|
||||
Revision ID: 0127
|
||||
Revises: 0126
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0127"
|
||||
down_revision = "0126"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop the FK constraint on tasks.contact_id
|
||||
op.drop_constraint("tasks_contact_id_fkey", "tasks", type_="foreignkey")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Re-create the FK constraint (best-effort — may fail if orphaned rows exist)
|
||||
op.create_foreign_key(
|
||||
"tasks_contact_id_fkey",
|
||||
"tasks",
|
||||
"contacts",
|
||||
["contact_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Create ai_decision_records table for oversight.
|
||||
|
||||
Revision ID: 0128
|
||||
Revises: 0127
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "0128"
|
||||
down_revision = "0127"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Use IF NOT EXISTS to avoid DuplicateTableError if table was already
|
||||
# created by Base.metadata.create_all() in prestart.sh
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ai_decision_records (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
owner_id UUID,
|
||||
agent_run_id UUID NOT NULL,
|
||||
recommendation TEXT NOT NULL,
|
||||
evidence JSONB NOT NULL DEFAULT '{}',
|
||||
reviewer_id UUID,
|
||||
decision VARCHAR(20),
|
||||
decision_timestamp VARCHAR(40),
|
||||
deviation_note TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_ai_decision_records_tenant_id ON ai_decision_records(tenant_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_ai_decision_records_agent_run_id ON ai_decision_records(agent_run_id)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("ai_decision_records")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Enable RLS for 8 tables that need tenant isolation.
|
||||
|
||||
Tables excluded (no tenant_id column):
|
||||
- outbox_deliveries: linked via event_outbox which has tenant_id
|
||||
- marketplace_listings: global plugin marketplace, not tenant-specific
|
||||
|
||||
Revision ID: 0129
|
||||
Revises: 0128
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0129"
|
||||
down_revision = "0128"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLES_NEEDING_RLS = [
|
||||
"ai_decision_records",
|
||||
"approval_requests",
|
||||
"automation_agent_run_steps",
|
||||
"roles",
|
||||
"sequences",
|
||||
"wiki_articles",
|
||||
"wiki_article_versions",
|
||||
"wiki_categories",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in TABLES_NEEDING_RLS:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;")
|
||||
op.execute(
|
||||
f"CREATE POLICY tenant_isolation ON {table} "
|
||||
f"FOR ALL USING (tenant_id = current_setting('app.tenant_id')::uuid);"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in TABLES_NEEDING_RLS:
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
|
||||
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Add backup_enabled column to system_settings table.
|
||||
|
||||
Revision ID: 0130
|
||||
Revises: 0129
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0130"
|
||||
down_revision = "0129"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"system_settings",
|
||||
sa.Column("backup_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("system_settings", "backup_enabled")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""knowledge extractions table
|
||||
|
||||
Revision ID: 0131
|
||||
Revises: 0130
|
||||
Create Date: 2026-08-20
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "0131"
|
||||
down_revision = "0130"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"knowledge_extractions",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("source_type", sa.String(50), nullable=False),
|
||||
sa.Column("source_id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("source_title", sa.String(500), nullable=True),
|
||||
sa.Column("extracted_entities", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("extracted_relationships", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("confidence", sa.Float, nullable=False, server_default=sa.text("0.0")),
|
||||
sa.Column("status", sa.String(30), nullable=False, server_default=sa.text("'pending'")),
|
||||
sa.Column("review_notes", sa.Text, nullable=True),
|
||||
sa.Column("llm_model", sa.String(100), nullable=True),
|
||||
sa.Column("llm_cost_usd", sa.Float, nullable=False, server_default=sa.text("0.0")),
|
||||
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("reviewed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_knowledge_ext_tenant_status", "knowledge_extractions", ["tenant_id", "status"])
|
||||
op.create_index("ix_knowledge_ext_source", "knowledge_extractions", ["tenant_id", "source_type", "source_id"])
|
||||
# RLS
|
||||
op.execute("ALTER TABLE knowledge_extractions ENABLE ROW LEVEL SECURITY;")
|
||||
op.execute("CREATE POLICY knowledge_extractions_tenant_isolation ON knowledge_extractions USING (tenant_id::text = current_setting('app.current_tenant_id', true));")
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("knowledge_extractions")
|
||||
@@ -0,0 +1,116 @@
|
||||
"""self-improvement tables: signals, patterns, proposals, impact measurements
|
||||
|
||||
Revision ID: 0132
|
||||
Revises: 0131
|
||||
Create Date: 2026-08-21
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "0132"
|
||||
down_revision = "0131"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. improvement_patterns (created first because signals has FK to it)
|
||||
op.create_table(
|
||||
"improvement_patterns",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("pattern_kind", sa.String(40), nullable=False),
|
||||
sa.Column("title", sa.String(300), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("target_type", sa.String(30), nullable=False),
|
||||
sa.Column("target_name", sa.String(200), nullable=False, server_default=sa.text("'unknown'")),
|
||||
sa.Column("evidence_refs", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("occurrence_count", sa.Integer, nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("confidence", sa.Float, nullable=False, server_default=sa.text("0.5")),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default=sa.text("'detected'")),
|
||||
sa.Column("proposed_action", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_impr_patterns_tenant_status", "improvement_patterns", ["tenant_id", "status"])
|
||||
op.create_index("ix_impr_patterns_tenant_target", "improvement_patterns", ["tenant_id", "target_type"])
|
||||
|
||||
# 2. improvement_signals
|
||||
op.create_table(
|
||||
"improvement_signals",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("source_type", sa.String(50), nullable=False),
|
||||
sa.Column("source_ref_id", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("source_metadata", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("summary", sa.Text, nullable=False),
|
||||
sa.Column("signal_kind", sa.String(30), nullable=False),
|
||||
sa.Column("severity", sa.String(20), nullable=False, server_default=sa.text("'info'")),
|
||||
sa.Column("confidence", sa.Float, nullable=False, server_default=sa.text("0.5")),
|
||||
sa.Column("pattern_id", UUID(as_uuid=True), sa.ForeignKey("improvement_patterns.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_impr_signals_tenant_kind", "improvement_signals", ["tenant_id", "signal_kind"])
|
||||
op.create_index("ix_impr_signals_tenant_source", "improvement_signals", ["tenant_id", "source_type"])
|
||||
op.create_index("ix_impr_signals_tenant_pattern", "improvement_signals", ["tenant_id", "pattern_id"])
|
||||
|
||||
# 3. improvement_proposals
|
||||
op.create_table(
|
||||
"improvement_proposals",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("pattern_id", UUID(as_uuid=True), sa.ForeignKey("improvement_patterns.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("title", sa.String(300), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("target_type", sa.String(30), nullable=False),
|
||||
sa.Column("target_ref_id", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("target_name", sa.String(200), nullable=True),
|
||||
sa.Column("version_number", sa.Integer, nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("proposed_config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("previous_config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("evidence_refs", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("rationale", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("expected_benefit", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("risk_assessment", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("evaluation_result", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("evaluated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("approval_request_id", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("approved_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("rolled_back_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("rollback_reason", sa.Text, nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default=sa.text("'draft'")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_impr_proposals_tenant_status", "improvement_proposals", ["tenant_id", "status"])
|
||||
op.create_index("ix_impr_proposals_tenant_target", "improvement_proposals", ["tenant_id", "target_type"])
|
||||
op.create_index("ix_impr_proposals_tenant_pattern", "improvement_proposals", ["tenant_id", "pattern_id"])
|
||||
|
||||
# 4. improvement_impact_measurements
|
||||
op.create_table(
|
||||
"improvement_impact_measurements",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("proposal_id", UUID(as_uuid=True), sa.ForeignKey("improvement_proposals.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("pre_metrics", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("post_metrics", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("delta", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("assessment", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("is_positive", sa.String(20), nullable=False, server_default=sa.text("'neutral'")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_impr_impact_tenant_proposal", "improvement_impact_measurements", ["tenant_id", "proposal_id"])
|
||||
|
||||
# RLS for all 4 tables
|
||||
for table in ["improvement_patterns", "improvement_signals", "improvement_proposals", "improvement_impact_measurements"]:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;")
|
||||
op.execute(f"CREATE POLICY {table}_tenant_isolation ON {table} USING (tenant_id::text = current_setting('app.current_tenant_id', true));")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ["improvement_impact_measurements", "improvement_proposals", "improvement_signals", "improvement_patterns"]:
|
||||
op.drop_table(table)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""compliance_incidents table for AI/privacy/security incident register
|
||||
|
||||
Revision ID: 0133
|
||||
Revises: 0132
|
||||
Create Date: 2026-08-21
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "0133"
|
||||
down_revision = "0132"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"compliance_incidents",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("incident_type", sa.String(30), nullable=False, server_default=sa.text("'ai'")),
|
||||
sa.Column("title", sa.String(300), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("affected_use_cases", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("affected_versions", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("provider", sa.String(100), nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("measures_taken", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("evidence_refs", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default=sa.text("'open'")),
|
||||
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("resolved_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_compliance_incidents_tenant_status", "compliance_incidents", ["tenant_id", "status"])
|
||||
op.create_index("ix_compliance_incidents_tenant_type", "compliance_incidents", ["tenant_id", "incident_type"])
|
||||
|
||||
# Add retention_config JSONB column to system_settings for compliance retention overrides
|
||||
op.add_column("system_settings", sa.Column("retention_config", JSONB, nullable=True, server_default=sa.text("'{}'::jsonb")))
|
||||
|
||||
# RLS
|
||||
op.execute("ALTER TABLE compliance_incidents ENABLE ROW LEVEL SECURITY;")
|
||||
op.execute("CREATE POLICY compliance_incidents_tenant_isolation ON compliance_incidents USING (tenant_id::text = current_setting('app.current_tenant_id', true));")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("system_settings", "retention_config")
|
||||
op.drop_table("compliance_incidents")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Fix notification_types column sizes — VARCHAR(20) too small for values.
|
||||
|
||||
Revision ID: 0134
|
||||
Revises: 0133
|
||||
Create Date: 2026-08-21
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0134"
|
||||
down_revision = "0133"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN type_key TYPE VARCHAR(100);")
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN plugin_name TYPE VARCHAR(100);")
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN category TYPE VARCHAR(50);")
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN label TYPE VARCHAR(200);")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN label TYPE VARCHAR(200);")
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN category TYPE VARCHAR(20);")
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN plugin_name TYPE VARCHAR(20);")
|
||||
op.execute("ALTER TABLE notification_types ALTER COLUMN type_key TYPE VARCHAR(20);")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Fix schema drifts — VARCHAR lengths + missing tables.
|
||||
|
||||
Revision ID: 0135
|
||||
Revises: 0134
|
||||
Create Date: 2026-08-21
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "0135"
|
||||
down_revision = "0134"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Fix VARCHAR length mismatches (model defines longer than DB)
|
||||
# Drop notifications_legacy view first — it depends on notifications.type column
|
||||
op.execute("DROP VIEW IF EXISTS notifications_legacy CASCADE;")
|
||||
op.execute("ALTER TABLE contacts ALTER COLUMN status TYPE VARCHAR(30);")
|
||||
op.execute("ALTER TABLE notifications ALTER COLUMN type TYPE VARCHAR(100);")
|
||||
op.execute("ALTER TABLE notification_preferences ALTER COLUMN type_key TYPE VARCHAR(100);")
|
||||
|
||||
# 2. Create missing table: forgejo_reported_errors (only if not exists)
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS forgejo_reported_errors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
dedup_key VARCHAR(64) NOT NULL UNIQUE,
|
||||
message TEXT NOT NULL,
|
||||
stack TEXT,
|
||||
forgejo_issue_number INTEGER,
|
||||
reported_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'reported'
|
||||
)
|
||||
""")
|
||||
|
||||
# 3. Create missing table: pgp_keys (only if not exists)
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS pgp_keys (
|
||||
id UUID DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
key_id VARCHAR(255) NOT NULL,
|
||||
encrypted_private_key TEXT NOT NULL,
|
||||
public_key_armored TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_pgp_keys_user ON pgp_keys (user_id);")
|
||||
op.execute("ALTER TABLE pgp_keys ENABLE ROW LEVEL SECURITY;")
|
||||
op.execute("DROP POLICY IF EXISTS pgp_keys_tenant_isolation ON pgp_keys;")
|
||||
op.execute("CREATE POLICY pgp_keys_tenant_isolation ON pgp_keys USING (tenant_id::text = current_setting('app.current_tenant_id', true));")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("pgp_keys")
|
||||
op.drop_table("forgejo_reported_errors")
|
||||
op.execute("ALTER TABLE notification_preferences ALTER COLUMN type_key TYPE VARCHAR(20);")
|
||||
op.execute("ALTER TABLE notifications ALTER COLUMN type TYPE VARCHAR(20);")
|
||||
op.execute("ALTER TABLE contacts ALTER COLUMN status TYPE VARCHAR(20);")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Fix RLS policies — app.tenant_id → app.current_tenant_id.
|
||||
|
||||
8 RLS policies in production reference 'app.tenant_id' which doesn't exist
|
||||
as a PostgreSQL parameter. The code uses 'app.current_tenant_id'.
|
||||
This causes 500 errors on roles, sequences, wiki, approval_requests,
|
||||
ai_decision_records, and automation_agent_run_steps.
|
||||
|
||||
Revision ID: 0136
|
||||
Revises: 0135
|
||||
Create Date: 2026-08-21
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0136"
|
||||
down_revision = "0135"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# All 8 tables with broken RLS policies referencing app.tenant_id
|
||||
TABLES_WITH_BAD_RLS = [
|
||||
"ai_decision_records",
|
||||
"approval_requests",
|
||||
"automation_agent_run_steps",
|
||||
"roles",
|
||||
"sequences",
|
||||
"wiki_articles",
|
||||
"wiki_article_versions",
|
||||
"wiki_categories",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in TABLES_WITH_BAD_RLS:
|
||||
# Drop old policy with app.tenant_id
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
|
||||
# Create new policy with app.current_tenant_id
|
||||
op.execute(
|
||||
f"CREATE POLICY tenant_isolation ON {table} "
|
||||
f"USING (tenant_id::text = current_setting('app.current_tenant_id', true));"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in TABLES_WITH_BAD_RLS:
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
|
||||
op.execute(
|
||||
f"CREATE POLICY tenant_isolation ON {table} "
|
||||
f"USING (tenant_id::text = current_setting('app.tenant_id', true));"
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Drop AI chat tables (migrated to comm conversations)
|
||||
|
||||
Revision ID: 0137
|
||||
Revises: 0136
|
||||
Create Date: 2026-08-21
|
||||
|
||||
AI chat functionality is now handled by the kommunikation plugin's
|
||||
comm_conversations and comm_messages tables. The old AI-specific tables
|
||||
(ai_chat_sessions, ai_chat_messages, ai_chat_attachments, ai_conversations,
|
||||
ai_messages) are no longer needed and are dropped.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0137"
|
||||
down_revision = "0136"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Use IF EXISTS to avoid errors if tables are already gone
|
||||
op.execute("DROP TABLE IF EXISTS ai_chat_attachments CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS ai_chat_messages CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS ai_chat_sessions CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS ai_messages CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS ai_conversations CASCADE")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Tables cannot be restored — data was migrated or was empty.
|
||||
pass
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Tags: parent_id, applicable_to, icon columns
|
||||
|
||||
Revision ID: 0138
|
||||
Revises: 0137
|
||||
Create Date: 2026-08-21
|
||||
|
||||
Adds parent_id for tree structure, applicable_to for entity-type filtering,
|
||||
and icon for per-tag icon selection.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0138"
|
||||
down_revision = "0137"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# parent_id for tree structure (self-referencing FK)
|
||||
op.add_column("tags", sa.Column("parent_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_tags_parent_id", "tags", "tags", ["parent_id"], ["id"], ondelete="SET NULL"
|
||||
)
|
||||
op.create_index("ix_tags_parent", "tags", ["parent_id"])
|
||||
|
||||
# applicable_to: list of entity types where this tag can be applied
|
||||
op.add_column("tags", sa.Column("applicable_to", JSONB, nullable=True))
|
||||
|
||||
# icon: icon name for frontend display
|
||||
op.add_column("tags", sa.Column("icon", sa.String(50), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tags", "icon")
|
||||
op.drop_column("tags", "applicable_to")
|
||||
op.drop_index("ix_tags_parent", table_name="tags")
|
||||
op.drop_constraint("fk_tags_parent_id", "tags", type_="foreignkey")
|
||||
op.drop_column("tags", "parent_id")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Reports: folder_id column for folder-based sorting
|
||||
|
||||
Revision ID: 0139
|
||||
Revises: 0138
|
||||
Create Date: 2026-08-21
|
||||
|
||||
Adds folder_id to report_templates for folder-based organization.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0139"
|
||||
down_revision = "0138"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("report_templates", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.create_index("ix_report_templates_folder", "report_templates", ["folder_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_report_templates_folder", table_name="report_templates")
|
||||
op.drop_column("report_templates", "folder_id")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Communication: folder_id in comm_conversations for folder organization
|
||||
|
||||
Revision ID: 0140
|
||||
Revises: 0139
|
||||
Create Date: 2026-08-21
|
||||
|
||||
Adds folder_id to comm_conversations for folder-based organization.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0140"
|
||||
down_revision = "0139"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("comm_conversations", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.create_index("ix_comm_conversations_folder", "comm_conversations", ["folder_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_comm_conversations_folder", table_name="comm_conversations")
|
||||
op.drop_column("comm_conversations", "folder_id")
|
||||
@@ -6,9 +6,12 @@ Supports keyword-based intent detection for common CRM operations.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Precompiled patterns for intent detection
|
||||
_PATTERNS = {
|
||||
"create_contact": re.compile(
|
||||
@@ -27,6 +30,37 @@ _PATTERNS = {
|
||||
"help": re.compile(r"\b(help|what can you do|assist)\b", re.IGNORECASE),
|
||||
}
|
||||
|
||||
# Plugin-contributed intents: pattern -> callable(query, context) -> list[dict] | None.
|
||||
# Registered via ``register_intent_pattern`` so plugins can extend the fallback
|
||||
# mapper without touching core code (Block H / HC-A).
|
||||
_CONTRIBUTED_INTENTS: list[tuple[re.Pattern[str], Any]] = []
|
||||
|
||||
|
||||
def register_intent_pattern(
|
||||
pattern: str | re.Pattern[str],
|
||||
handler: Any,
|
||||
*,
|
||||
owner: str = "",
|
||||
) -> None:
|
||||
"""Register a plugin-contributed intent for the fallback action mapper.
|
||||
|
||||
Args:
|
||||
pattern: Regex (compiled or raw string) matching the user query.
|
||||
handler: Callable ``(query, context) -> list[dict] | None`` producing
|
||||
proposed actions when the pattern matches.
|
||||
owner: Optional plugin name, used by ``unregister_intent_patterns``.
|
||||
"""
|
||||
compiled = re.compile(pattern) if isinstance(pattern, str) else pattern
|
||||
_CONTRIBUTED_INTENTS.append((compiled, handler))
|
||||
|
||||
|
||||
def unregister_intent_patterns(owner: str) -> None:
|
||||
"""Remove all intents contributed by ``owner`` (plugin deactivation)."""
|
||||
global _CONTRIBUTED_INTENTS
|
||||
_CONTRIBUTED_INTENTS = [
|
||||
entry for entry in _CONTRIBUTED_INTENTS if getattr(entry[1], "owner_tag", None) != owner
|
||||
]
|
||||
|
||||
# Name extraction patterns - using single-quoted strings to avoid escaping issues
|
||||
_NAME_PATTERNS = [
|
||||
re.compile(r"\b(?:named|called|for)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||
@@ -147,6 +181,16 @@ def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> l
|
||||
}
|
||||
)
|
||||
|
||||
# --- Plugin-contributed intents (Block H / HC-A) ---
|
||||
for pattern, handler in _CONTRIBUTED_INTENTS:
|
||||
try:
|
||||
if pattern.search(q):
|
||||
contributed = handler(query, context)
|
||||
if contributed:
|
||||
actions.extend(contributed)
|
||||
except Exception:
|
||||
logger.warning("Contributed intent handler failed", exc_info=True)
|
||||
|
||||
# --- Generic fallback ---
|
||||
if not actions:
|
||||
if _PATTERNS["help"].search(q):
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
"""Core ReAct (Reasoning + Acting) loop for AI agents.
|
||||
|
||||
Implements a true ReAct loop that alternates between LLM reasoning and tool
|
||||
execution. Each step records the thought (LLM content), action (tool name),
|
||||
action_input (tool arguments), and observation (tool result).
|
||||
|
||||
The loop terminates when:
|
||||
- The LLM returns a final response without tool calls (completed)
|
||||
- max_steps is reached (stopped_max_steps)
|
||||
- timeout is exceeded (stopped_timeout)
|
||||
- A permanent error occurs (stopped_error)
|
||||
|
||||
Error handling uses ``ErrorCategory`` from ``app.core.error_codes``:
|
||||
- TRANSIENT → retry the LLM call (up to 3 retries per step)
|
||||
- PERMANENT → stop the loop immediately
|
||||
- PARTIAL → continue with partial results
|
||||
|
||||
Usage::
|
||||
|
||||
from app.ai.agent_loop import run_react_loop
|
||||
|
||||
result = await run_react_loop(
|
||||
agent_definition=agent,
|
||||
messages=[{"role": "user", "content": "Summarize recent emails"}],
|
||||
tools=tool_schemas,
|
||||
tool_registry=registry,
|
||||
db=db_session,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
print(result.final_content, result.total_cost_usd, result.steps_taken)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.ai.llm_client import llm_complete
|
||||
from app.core.error_codes import ErrorCategory, classify_exception
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai.tool_registry import ToolRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum retries for transient errors per LLM step
|
||||
_MAX_TRANSIENT_RETRIES = 3
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Data structures
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReActStep:
|
||||
"""A single step in the ReAct loop (Thought → Action → Observation)."""
|
||||
|
||||
step_number: int
|
||||
thought: str # LLM content before tool calls
|
||||
action: str | None # Tool name (None if final response)
|
||||
action_input: dict[str, Any] | None # Tool arguments
|
||||
observation: str | None # Tool result
|
||||
cost_usd: float
|
||||
timestamp: str # ISO format
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReActResult:
|
||||
"""Final result of the ReAct loop."""
|
||||
|
||||
final_content: str
|
||||
steps: list[ReActStep] = field(default_factory=list)
|
||||
total_cost_usd: float = 0.0
|
||||
steps_taken: int = 0
|
||||
status: str = "completed" # completed | stopped_max_steps | stopped_timeout | stopped_error
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Core loop
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extract_tool_calls(raw_response: Any) -> list[dict[str, Any]]:
|
||||
"""Extract tool calls from a LiteLLM raw response.
|
||||
|
||||
Returns a list of dicts with keys: ``id``, ``name``, ``arguments``.
|
||||
"""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
try:
|
||||
msg = raw_response.choices[0].message
|
||||
if hasattr(msg, "tool_calls") and msg.tool_calls:
|
||||
for tc in msg.tool_calls:
|
||||
tool_calls.append({
|
||||
"id": tc.id or "",
|
||||
"name": tc.function.name if tc.function else "",
|
||||
"arguments": tc.function.arguments if tc.function and tc.function.arguments else "{}",
|
||||
})
|
||||
except (AttributeError, IndexError, TypeError) as exc:
|
||||
logger.debug("Failed to extract tool calls from response: %s", exc)
|
||||
return tool_calls
|
||||
|
||||
|
||||
async def _execute_tool(
|
||||
tool_registry: ToolRegistry,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
) -> str:
|
||||
"""Execute a single tool call via the registry.
|
||||
|
||||
Returns the tool result as a string, or an error message.
|
||||
"""
|
||||
tool = tool_registry.get(tool_name)
|
||||
if tool is None:
|
||||
return f"Error: Tool '{tool_name}' not found"
|
||||
|
||||
try:
|
||||
result = await tool.handler(arguments=arguments, context=context)
|
||||
return result if isinstance(result, str) else json.dumps(result)
|
||||
except Exception as exc:
|
||||
logger.exception("Tool '%s' execution failed", tool_name)
|
||||
return f"Error: {exc}"
|
||||
|
||||
|
||||
async def run_react_loop(
|
||||
agent_definition: Any, # AgentDefinition from automation models
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
tool_registry: ToolRegistry,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
agent_run_id: uuid.UUID | None = None,
|
||||
max_steps: int = 20,
|
||||
timeout_seconds: int = 300,
|
||||
trace_id: str | None = None,
|
||||
on_step: Callable | None = None,
|
||||
dry_run: bool = False,
|
||||
require_approval: bool = False,
|
||||
approval_tools: list[str] | None = None,
|
||||
) -> ReActResult:
|
||||
"""Execute a ReAct loop: LLM reasoning → tool execution → repeat.
|
||||
|
||||
Args:
|
||||
agent_definition: AgentDefinition with llm_model, system_prompt, etc.
|
||||
messages: Initial chat messages (without system prompt).
|
||||
tools: OpenAI-format tool schemas for function calling.
|
||||
tool_registry: ToolRegistry instance for tool execution.
|
||||
db: Async DB session.
|
||||
tenant_id: Tenant ID for multi-tenancy.
|
||||
user_id: User ID for permission context.
|
||||
agent_run_id: Optional AgentRun ID for step persistence.
|
||||
max_steps: Maximum loop iterations (default 20).
|
||||
timeout_seconds: Overall timeout (default 300).
|
||||
trace_id: Optional trace ID for correlation.
|
||||
on_step: Optional async callback fired after each step.
|
||||
dry_run: When True, tool execution is simulated — tool handlers are
|
||||
NOT called. A mock result is returned instead and steps are still
|
||||
logged with real LLM cost.
|
||||
|
||||
Returns:
|
||||
ReActResult with final content, steps, cost, and status.
|
||||
"""
|
||||
from app.core.hooks import do_action
|
||||
|
||||
result = ReActResult(final_content="", status="completed")
|
||||
start_time = time.monotonic()
|
||||
|
||||
# Build LLM parameters from agent definition
|
||||
litellm_model = getattr(agent_definition, "llm_model", None) or "gpt-4o"
|
||||
system_prompt = getattr(agent_definition, "system_prompt", "") or "You are a helpful AI assistant."
|
||||
api_key = getattr(agent_definition, "api_key", None)
|
||||
api_base = getattr(agent_definition, "api_base", None)
|
||||
provider = getattr(agent_definition, "provider", None)
|
||||
max_tokens = getattr(agent_definition, "max_tokens", None) or 1000
|
||||
|
||||
# Build the full message list with system prompt prepended
|
||||
full_messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
*messages,
|
||||
]
|
||||
|
||||
tool_context: dict[str, Any] = {
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
"db": db,
|
||||
}
|
||||
|
||||
# Audit helper — records every tool call in the audit log.
|
||||
async def _audit_tool_call(
|
||||
step_number: int,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
result: str,
|
||||
cost_usd: float,
|
||||
) -> None:
|
||||
"""Create an audit log entry for a single tool call."""
|
||||
try:
|
||||
from app.core.audit import log_audit
|
||||
|
||||
await log_audit(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action="agent.tool_call",
|
||||
entity_type="agent_run",
|
||||
entity_id=agent_run_id,
|
||||
details={
|
||||
"agent_run_id": str(agent_run_id) if agent_run_id else None,
|
||||
"step_number": step_number,
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"result": result[:2000],
|
||||
"cost_usd": cost_usd,
|
||||
"dry_run": dry_run,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to audit tool call '%s'", tool_name)
|
||||
|
||||
for step_num in range(1, max_steps + 1):
|
||||
# ── Timeout check ──
|
||||
elapsed = time.monotonic() - start_time
|
||||
if elapsed >= timeout_seconds:
|
||||
result.status = "stopped_timeout"
|
||||
result.error = f"Timeout after {elapsed:.1f}s (limit {timeout_seconds}s)"
|
||||
logger.warning("ReAct loop timed out at step %d: %s", step_num, result.error)
|
||||
break
|
||||
|
||||
# ── LLM call with transient retry ──
|
||||
llm_result: dict[str, Any] | None = None
|
||||
last_error: str | None = None
|
||||
|
||||
for retry in range(_MAX_TRANSIENT_RETRIES + 1):
|
||||
try:
|
||||
llm_result = await llm_complete(
|
||||
model=litellm_model,
|
||||
messages=full_messages,
|
||||
tools=tools if tools else None,
|
||||
temperature=0.3,
|
||||
max_tokens=max_tokens,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
provider=provider,
|
||||
trace_id=trace_id,
|
||||
tenant_id=tenant_id,
|
||||
db=db,
|
||||
)
|
||||
break
|
||||
except Exception as exc:
|
||||
last_error = str(exc)
|
||||
category = classify_exception(exc)
|
||||
|
||||
if category == ErrorCategory.PERMANENT:
|
||||
result.status = "stopped_error"
|
||||
result.error = f"Permanent error at step {step_num}: {exc}"
|
||||
logger.error("ReAct loop permanent error: %s", result.error)
|
||||
return result
|
||||
|
||||
if category == ErrorCategory.TRANSIENT and retry < _MAX_TRANSIENT_RETRIES:
|
||||
backoff = 2 ** retry
|
||||
logger.warning(
|
||||
"Transient error at step %d (retry %d/%d): %s — retrying in %ds",
|
||||
step_num, retry + 1, _MAX_TRANSIENT_RETRIES, exc, backoff,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
# PARTIAL or exhausted retries
|
||||
if category == ErrorCategory.PARTIAL:
|
||||
logger.warning("Partial error at step %d: %s — continuing", step_num, exc)
|
||||
last_error = str(exc)
|
||||
break
|
||||
|
||||
# Exhausted transient retries
|
||||
result.status = "stopped_error"
|
||||
result.error = f"Error after {retry + 1} retries at step {step_num}: {exc}"
|
||||
logger.error("ReAct loop error: %s", result.error)
|
||||
return result
|
||||
|
||||
if llm_result is None:
|
||||
result.status = "stopped_error"
|
||||
result.error = f"LLM call failed at step {step_num}: {last_error}"
|
||||
return result
|
||||
|
||||
# ── Extract response data ──
|
||||
content = llm_result.get("content", "")
|
||||
cost_usd = llm_result.get("cost_usd", 0.0)
|
||||
result.total_cost_usd += cost_usd
|
||||
|
||||
tool_calls = _extract_tool_calls(llm_result.get("raw_response"))
|
||||
|
||||
# ── No tool calls → final response ──
|
||||
if not tool_calls:
|
||||
step = ReActStep(
|
||||
step_number=step_num,
|
||||
thought=content,
|
||||
action=None,
|
||||
action_input=None,
|
||||
observation=None,
|
||||
cost_usd=cost_usd,
|
||||
timestamp=datetime.now(UTC).isoformat(),
|
||||
)
|
||||
result.steps.append(step)
|
||||
result.final_content = content
|
||||
result.steps_taken = step_num
|
||||
|
||||
# Fire hook
|
||||
await do_action(
|
||||
"agent.step",
|
||||
agent_id=str(getattr(agent_definition, "id", "")),
|
||||
step_number=step_num,
|
||||
thought=content,
|
||||
action=None,
|
||||
observation=None,
|
||||
cost_usd=cost_usd,
|
||||
agent_run_id=str(agent_run_id) if agent_run_id else None,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
# Callback
|
||||
if on_step:
|
||||
try:
|
||||
await on_step(step)
|
||||
except Exception:
|
||||
logger.debug("on_step callback failed", exc_info=True)
|
||||
|
||||
break
|
||||
|
||||
# ── Execute tool calls ──
|
||||
# Append assistant message with tool calls to conversation
|
||||
full_messages.append({
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc["id"],
|
||||
"type": "function",
|
||||
"function": {"name": tc["name"], "arguments": tc["arguments"]},
|
||||
}
|
||||
for tc in tool_calls
|
||||
],
|
||||
})
|
||||
|
||||
# Execute each tool call and collect observations
|
||||
observations: list[str] = []
|
||||
for tc in tool_calls:
|
||||
tool_name = tc["name"]
|
||||
try:
|
||||
args = json.loads(tc["arguments"]) if tc["arguments"] else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
logger.warning("Invalid JSON arguments for tool '%s': %s", tool_name, tc["arguments"])
|
||||
|
||||
if dry_run:
|
||||
observation = json.dumps(
|
||||
{
|
||||
"dry_run": True,
|
||||
"would_execute": tool_name,
|
||||
"arguments": args,
|
||||
}
|
||||
)
|
||||
elif require_approval and (approval_tools is None or tool_name in (approval_tools or [])):
|
||||
# I-APPR-LOOP: Human-in-the-Loop Approval
|
||||
# Create an ApprovalRequest and pause the loop
|
||||
try:
|
||||
from app.core.approval import create_approval_request
|
||||
pass # agent_workstream removed
|
||||
|
||||
approval = await create_approval_request(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
entity_type="agent_run",
|
||||
entity_id=agent_run_id or uuid.uuid4(),
|
||||
action=f"tool:{tool_name}",
|
||||
requested_by=user_id,
|
||||
requested_by_type="agent",
|
||||
)
|
||||
|
||||
# Post approval request to Communication (I-WORK-HANDOFF)
|
||||
if agent_run_id:
|
||||
try:
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
komm = get_contract_registry().get("kommunikation")
|
||||
if komm:
|
||||
agent_id = getattr(agent_definition, "id", uuid.uuid4())
|
||||
room_title = f"Agent: {getattr(agent_definition, 'name', 'Agent')}"
|
||||
conv_id = await komm.find_locked_room_id(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
plugin_name="automation",
|
||||
title=room_title,
|
||||
)
|
||||
if conv_id:
|
||||
await komm.send_message(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=conv_id,
|
||||
sender_id=agent_id,
|
||||
sender_type="agent",
|
||||
content=f"Approval required for tool '{tool_name}'",
|
||||
content_format="text",
|
||||
blocks=[
|
||||
{
|
||||
"block_type": "approval_request",
|
||||
"block_data": {
|
||||
"title": f"Approval: {tool_name}",
|
||||
"description": f"Agent wants to execute tool '{tool_name}' with arguments: {json.dumps(args)[:300]}",
|
||||
"approval_id": str(approval.id),
|
||||
"status": "pending",
|
||||
},
|
||||
"sort_order": 0,
|
||||
}
|
||||
],
|
||||
metadata={"approval_id": str(approval.id), "agent_run_id": str(agent_run_id)},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to post approval request to communication", exc_info=True)
|
||||
|
||||
# Pause the loop — return with waiting_for_approval status
|
||||
result.status = "waiting_for_approval"
|
||||
result.error = f"Tool '{tool_name}' requires human approval (request_id: {approval.id})"
|
||||
result.steps_taken = step_num
|
||||
result.final_content = f"I need approval to execute tool '{tool_name}'. Approval request {approval.id} has been created."
|
||||
logger.info("Agent loop paused for approval on tool '%s' (request: %s)", tool_name, approval.id)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning("Failed to create approval request for tool '%s': %s", tool_name, e)
|
||||
observation = json.dumps({"error": f"Approval required but failed to create request: {e}"})
|
||||
else:
|
||||
observation = await _execute_tool(tool_registry, tool_name, args, tool_context)
|
||||
observations.append(observation)
|
||||
|
||||
# Audit every tool call (real or simulated)
|
||||
await _audit_tool_call(
|
||||
step_number=step_num,
|
||||
tool_name=tool_name,
|
||||
arguments=args,
|
||||
result=observation,
|
||||
cost_usd=cost_usd / len(tool_calls) if tool_calls else cost_usd,
|
||||
)
|
||||
|
||||
# Feed tool result back into conversation
|
||||
full_messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc["id"],
|
||||
"content": observation,
|
||||
})
|
||||
|
||||
# Record step
|
||||
step = ReActStep(
|
||||
step_number=step_num,
|
||||
thought=content,
|
||||
action=tool_name,
|
||||
action_input=args,
|
||||
observation=observation,
|
||||
cost_usd=cost_usd / len(tool_calls) if tool_calls else cost_usd,
|
||||
timestamp=datetime.now(UTC).isoformat(),
|
||||
)
|
||||
result.steps.append(step)
|
||||
|
||||
# Fire hook
|
||||
await do_action(
|
||||
"agent.step",
|
||||
agent_id=str(getattr(agent_definition, "id", "")),
|
||||
step_number=step_num,
|
||||
thought=content,
|
||||
action=tool_name,
|
||||
observation=observation,
|
||||
cost_usd=cost_usd,
|
||||
agent_run_id=str(agent_run_id) if agent_run_id else None,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
# Callback
|
||||
if on_step:
|
||||
try:
|
||||
await on_step(step)
|
||||
except Exception:
|
||||
logger.debug("on_step callback failed", exc_info=True)
|
||||
|
||||
result.steps_taken = step_num
|
||||
|
||||
# If this was the last allowed step, stop gracefully
|
||||
if step_num >= max_steps:
|
||||
result.status = "stopped_max_steps"
|
||||
result.error = f"Reached max_steps limit ({max_steps})"
|
||||
result.final_content = content
|
||||
logger.warning("ReAct loop stopped at max_steps=%d", max_steps)
|
||||
break
|
||||
|
||||
# If loop completed without a final response (e.g. all steps had tool calls)
|
||||
if not result.final_content and result.steps:
|
||||
result.final_content = result.steps[-1].thought or ""
|
||||
|
||||
if result.status == "completed" and not result.final_content:
|
||||
result.final_content = ""
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Agent permission context resolution for AI agents.
|
||||
|
||||
Resolves the effective permissions available to an agent run as the
|
||||
intersection of the user's (or run-as user's) RBAC permissions, the agent's
|
||||
configured tools/skills, and the tools each skill is allowed to use.
|
||||
|
||||
Effective = User/Run-as ∩ Agent ∩ Skill ∩ Tool
|
||||
|
||||
Key principles:
|
||||
- Skills orchestrate tools but NEVER grant additional permissions.
|
||||
- Every tool/service call re-checks permissions — rights are NOT frozen
|
||||
for a run.
|
||||
- System admins get all tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.error_codes import ApiError
|
||||
from app.core.permissions import check_permission, resolve_permissions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentPermissionContext:
|
||||
"""Effective permission context for a single agent run."""
|
||||
|
||||
user_id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
run_as_user_id: uuid.UUID | None
|
||||
user_permissions: dict[str, Any] # RBAC permissions from Role
|
||||
agent_tool_ids: list[str]
|
||||
agent_skill_ids: list[str]
|
||||
effective_tool_ids: list[str] # After intersection
|
||||
is_system_admin: bool = False
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
"""Check whether the run-as user has the given RBAC permission."""
|
||||
return check_permission(self.user_permissions, permission)
|
||||
|
||||
def can_use_tool(self, tool_id: str) -> bool:
|
||||
"""Check whether the agent may call the given tool."""
|
||||
return tool_id in self.effective_tool_ids
|
||||
|
||||
|
||||
def _resolve_effective_tool_ids(
|
||||
agent_definition: Any,
|
||||
user_permissions: dict[str, Any],
|
||||
) -> list[str]:
|
||||
"""Compute the effective tool IDs after User ∩ Agent ∩ Skill ∩ Tool.
|
||||
|
||||
Mirrors the semantics of ``app.ai.agent_tools.get_agent_tools``: skills
|
||||
orchestrate tools but never grant additional permissions.
|
||||
"""
|
||||
from app.ai.skill_registry import get_skill_registry
|
||||
from app.ai.tool_registry import get_tool_registry
|
||||
|
||||
agent_tool_ids: list[str] = list(getattr(agent_definition, "tool_ids", None) or [])
|
||||
agent_skill_ids: list[str] = list(getattr(agent_definition, "skill_ids", None) or [])
|
||||
|
||||
skill_registry = get_skill_registry()
|
||||
skills = skill_registry.get_by_names(agent_skill_ids)
|
||||
|
||||
direct_tool_ids = set(agent_tool_ids)
|
||||
skill_tool_ids: set[str] = set()
|
||||
for skill in skills:
|
||||
skill_tool_ids.update(skill.allowed_tool_ids or [])
|
||||
|
||||
# Tools directly on the agent, plus tools reachable via skills that are
|
||||
# also directly on the agent (skills never widen the agent's tool set).
|
||||
available_tool_ids = direct_tool_ids | (direct_tool_ids & skill_tool_ids)
|
||||
|
||||
tool_registry = get_tool_registry()
|
||||
tools = tool_registry.get_by_names(sorted(available_tool_ids))
|
||||
|
||||
permitted = [
|
||||
tool
|
||||
for tool in tools
|
||||
if not getattr(tool, "required_permission", None)
|
||||
or check_permission(user_permissions, tool.required_permission)
|
||||
]
|
||||
return [tool.name for tool in permitted]
|
||||
|
||||
|
||||
async def resolve_agent_permissions(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
agent_definition: Any,
|
||||
run_as_user_id: uuid.UUID | None = None,
|
||||
) -> AgentPermissionContext:
|
||||
"""Resolve effective permissions for an agent run.
|
||||
|
||||
Effective = User/Run-as ∩ Agent ∩ Skill ∩ Tool.
|
||||
|
||||
Args:
|
||||
db: Async DB session.
|
||||
tenant_id: Tenant ID for multi-tenancy.
|
||||
user_id: The user requesting the run (permission source).
|
||||
agent_definition: AgentDefinition with tool_ids and skill_ids.
|
||||
run_as_user_id: Optional user the agent runs as. When provided, the
|
||||
run-as user's permissions are used instead of the requester's.
|
||||
|
||||
Returns:
|
||||
AgentPermissionContext with the resolved effective tool IDs.
|
||||
"""
|
||||
effective_user_id = run_as_user_id or user_id
|
||||
user_permissions = await resolve_permissions(db, effective_user_id, tenant_id)
|
||||
is_system_admin = bool(user_permissions.get("is_system_admin", False))
|
||||
|
||||
agent_tool_ids: list[str] = list(getattr(agent_definition, "tool_ids", None) or [])
|
||||
agent_skill_ids: list[str] = list(getattr(agent_definition, "skill_ids", None) or [])
|
||||
|
||||
if is_system_admin:
|
||||
effective_tool_ids = list(agent_tool_ids)
|
||||
else:
|
||||
effective_tool_ids = _resolve_effective_tool_ids(agent_definition, user_permissions)
|
||||
|
||||
return AgentPermissionContext(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
run_as_user_id=run_as_user_id,
|
||||
user_permissions=user_permissions,
|
||||
agent_tool_ids=agent_tool_ids,
|
||||
agent_skill_ids=agent_skill_ids,
|
||||
effective_tool_ids=effective_tool_ids,
|
||||
is_system_admin=is_system_admin,
|
||||
)
|
||||
|
||||
|
||||
async def check_entity_lock(
|
||||
db: AsyncSession,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
expected_version: int,
|
||||
) -> bool:
|
||||
"""Optimistic-lock check: raise ApiError('conflict') on version mismatch.
|
||||
|
||||
Loads the entity's ``version`` column. If the current version differs from
|
||||
``expected_version``, raises ``ApiError`` with code ``conflict``. Models
|
||||
without a ``version`` column are treated as unlocked (no-op).
|
||||
|
||||
Returns True when the lock check passes.
|
||||
"""
|
||||
from app.services.entity_permission_service import ENTITY_MODELS
|
||||
|
||||
model = ENTITY_MODELS.get(entity_type)
|
||||
if model is None or not hasattr(model, "version"):
|
||||
return True
|
||||
|
||||
result = await db.execute(select(model.version).where(model.id == entity_id))
|
||||
current_version = result.scalar_one_or_none()
|
||||
if current_version is None:
|
||||
raise ApiError(code="not_found", detail=f"{entity_type} not found")
|
||||
|
||||
if int(current_version) != int(expected_version):
|
||||
raise ApiError(
|
||||
code="conflict",
|
||||
detail=(
|
||||
f"{entity_type} {entity_id} was modified concurrently "
|
||||
f"(expected version {expected_version}, current {current_version})"
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def filter_visible_agents(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
agents: list,
|
||||
) -> list:
|
||||
"""Filter agents visible to a user based on agents:read + EntityPermission.
|
||||
|
||||
A user sees an agent when they have the ``agents:read`` permission AND the
|
||||
agent is visible via ownership, tenant-ownership, or entity_permissions.
|
||||
System admins see all agents.
|
||||
"""
|
||||
user_permissions = await resolve_permissions(db, user_id, tenant_id)
|
||||
if user_permissions.get("is_system_admin"):
|
||||
return list(agents)
|
||||
if not check_permission(user_permissions, "agents:read"):
|
||||
return []
|
||||
|
||||
from app.services.permission_resolver import get_visible_ids
|
||||
|
||||
visible_ids, _ = await get_visible_ids(db, tenant_id, user_id, "agent_definition")
|
||||
return [a for a in agents if a.id in visible_ids]
|
||||
|
||||
|
||||
async def check_agent_execute_permission(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Check if a user has agents:execute permission for a specific agent.
|
||||
|
||||
Requires the ``agents:execute`` RBAC permission AND entity-level access
|
||||
(owner, tenant-owned, or shared via entity_permissions). System admins
|
||||
always pass.
|
||||
"""
|
||||
user_permissions = await resolve_permissions(db, user_id, tenant_id)
|
||||
if user_permissions.get("is_system_admin"):
|
||||
return True
|
||||
if not check_permission(user_permissions, "agents:execute"):
|
||||
return False
|
||||
|
||||
from app.services.permission_resolver import check_entity_access
|
||||
|
||||
return await check_entity_access(
|
||||
db, tenant_id, user_id, "agent_definition", agent_id, required_level="read"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentPermissionContext",
|
||||
"resolve_agent_permissions",
|
||||
"check_entity_lock",
|
||||
"filter_visible_agents",
|
||||
"check_agent_execute_permission",
|
||||
]
|
||||
@@ -0,0 +1,155 @@
|
||||
"""SSE streaming for the ReAct agent loop.
|
||||
|
||||
Wraps ``run_react_loop`` from ``app.ai.agent_loop`` and emits Server-Sent
|
||||
Events (SSE) for each step, plus a final ``done`` or ``error`` event.
|
||||
|
||||
Events emitted:
|
||||
- ``event: step`` — JSON {step_number, thought, action, action_input, observation, cost_usd}
|
||||
- ``event: status`` — JSON {status: "running", step: N}
|
||||
- ``event: done`` — JSON {status, total_cost, steps_taken, final_content}
|
||||
- ``event: error`` — JSON {error, trace_id}
|
||||
|
||||
Trace modes:
|
||||
- ``standard`` — step events include action + result only (no thought)
|
||||
- ``extended`` — step events also include the thought/reasoning
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any, AsyncGenerator
|
||||
|
||||
from app.ai.agent_loop import ReActStep, run_react_loop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# SSE helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sse(event: str, data: dict[str, Any]) -> str:
|
||||
"""Format a single SSE event as ``event: <name>\ndata: <json>\n\n``."""
|
||||
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
def _step_event(step: ReActStep, trace_mode: str) -> str:
|
||||
"""Build the SSE ``step`` event for a ReAct step."""
|
||||
data: dict[str, Any] = {
|
||||
"step_number": step.step_number,
|
||||
"action": step.action,
|
||||
"action_input": step.action_input,
|
||||
"observation": step.observation,
|
||||
"cost_usd": step.cost_usd,
|
||||
}
|
||||
if trace_mode == "extended":
|
||||
data["thought"] = step.thought
|
||||
return _sse("step", data)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Streaming loop
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def stream_react_loop(
|
||||
agent_definition: Any,
|
||||
user_message: str,
|
||||
tools: list[dict],
|
||||
tool_registry: Any,
|
||||
db: AsyncSession | None,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
agent_run_id: uuid.UUID | None = None,
|
||||
max_steps: int = 20,
|
||||
timeout_seconds: int = 300,
|
||||
trace_id: str | None = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Run the ReAct loop and yield SSE-formatted events.
|
||||
|
||||
Args:
|
||||
agent_definition: AgentDefinition with llm_model, system_prompt, etc.
|
||||
user_message: The user's message to the agent.
|
||||
tools: OpenAI-format tool schemas for function calling.
|
||||
tool_registry: ToolRegistry instance for tool execution.
|
||||
db: Async DB session.
|
||||
tenant_id: Tenant ID for multi-tenancy.
|
||||
user_id: User ID for permission context.
|
||||
agent_run_id: Optional AgentRun ID for step persistence.
|
||||
max_steps: Maximum loop iterations (default 20).
|
||||
timeout_seconds: Overall timeout (default 300).
|
||||
trace_id: Optional trace ID for correlation.
|
||||
|
||||
Yields:
|
||||
SSE-formatted event strings.
|
||||
"""
|
||||
trace_mode = getattr(agent_definition, "trace_mode", "standard") or "standard"
|
||||
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
|
||||
async def on_step(step: ReActStep) -> None:
|
||||
"""Push step + status events into the queue."""
|
||||
await queue.put(_step_event(step, trace_mode))
|
||||
await queue.put(
|
||||
_sse("status", {"status": "running", "step": step.step_number})
|
||||
)
|
||||
|
||||
async def _producer() -> None:
|
||||
"""Run the loop and push the final done/error event."""
|
||||
try:
|
||||
result = await run_react_loop(
|
||||
agent_definition=agent_definition,
|
||||
messages=[{"role": "user", "content": user_message}],
|
||||
tools=tools,
|
||||
tool_registry=tool_registry,
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
agent_run_id=agent_run_id,
|
||||
max_steps=max_steps,
|
||||
timeout_seconds=timeout_seconds,
|
||||
trace_id=trace_id,
|
||||
on_step=on_step,
|
||||
)
|
||||
await queue.put(
|
||||
_sse(
|
||||
"done",
|
||||
{
|
||||
"status": result.status,
|
||||
"total_cost": result.total_cost_usd,
|
||||
"steps_taken": result.steps_taken,
|
||||
"final_content": result.final_content,
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — stream must not crash the consumer
|
||||
logger.exception("ReAct streaming loop failed")
|
||||
await queue.put(
|
||||
_sse("error", {"error": str(exc), "trace_id": trace_id})
|
||||
)
|
||||
finally:
|
||||
await queue.put(None) # sentinel
|
||||
|
||||
producer_task = asyncio.create_task(_producer())
|
||||
try:
|
||||
while True:
|
||||
event = await queue.get()
|
||||
if event is None:
|
||||
break
|
||||
yield event
|
||||
finally:
|
||||
if not producer_task.done():
|
||||
producer_task.cancel()
|
||||
try:
|
||||
await producer_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["stream_react_loop"]
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Tool-/Skill-Binding for AI agents.
|
||||
|
||||
Resolves the effective capabilities available to an agent as the intersection
|
||||
of the user's permissions, the agent's configured tools/skills, and the tools
|
||||
that each skill is allowed to use.
|
||||
|
||||
Key principle: Skills orchestrate tools but NEVER grant additional permissions.
|
||||
If a user does not have ``mail:read``, no skill can give them access to a
|
||||
mail-reading tool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.ai.skill_registry import SkillDefinition, SkillRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _user_has_permission(
|
||||
user_permissions: dict[str, Any],
|
||||
required_permission: str | None,
|
||||
) -> bool:
|
||||
"""Check whether the user has the required permission for a tool.
|
||||
|
||||
A tool without a required permission is always allowed. The check uses the
|
||||
same semantics as ``app.core.permissions.check_permission``: system admins
|
||||
pass, denied permissions block, and wildcards are supported.
|
||||
"""
|
||||
if not required_permission:
|
||||
return True
|
||||
if user_permissions.get("is_system_admin"):
|
||||
return True
|
||||
|
||||
denied = set(user_permissions.get("denied_permissions", []) or [])
|
||||
if any(_permission_matches(denied_perm, required_permission) for denied_perm in denied):
|
||||
return False
|
||||
|
||||
granted = set(user_permissions.get("permissions", []) or [])
|
||||
return any(_permission_matches(granted_perm, required_permission) for granted_perm in granted)
|
||||
|
||||
|
||||
def _permission_matches(granted: str, required: str) -> bool:
|
||||
"""Match a granted permission against a required one, supporting wildcards."""
|
||||
if granted == required:
|
||||
return True
|
||||
g_parts = granted.split(":")
|
||||
r_parts = required.split(":")
|
||||
if len(g_parts) != len(r_parts):
|
||||
return False
|
||||
for g_part, r_part in zip(g_parts, r_parts, strict=False):
|
||||
if g_part == "*":
|
||||
continue
|
||||
if g_part != r_part:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_agent_tools(
|
||||
agent_definition: Any,
|
||||
tool_registry: Any,
|
||||
skill_registry: SkillRegistry,
|
||||
user_permissions: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], list[SkillDefinition]]:
|
||||
"""Get the tools and skills available to this agent.
|
||||
|
||||
Effective capabilities = User/Run-as ∩ Agent ∩ Skill ∩ Tool.
|
||||
|
||||
Args:
|
||||
agent_definition: AgentDefinition with ``tool_ids`` and ``skill_ids``.
|
||||
tool_registry: ToolRegistry with ``get_by_names`` and ``get``.
|
||||
skill_registry: SkillRegistry used to resolve skill names.
|
||||
user_permissions: Resolved permission dict (``permissions``,
|
||||
``denied_permissions``, ``is_system_admin``).
|
||||
|
||||
Returns:
|
||||
A tuple of (tool_schemas, skills). ``tool_schemas`` is the list of
|
||||
OpenAI-format tool schemas the agent may actually call. ``skills`` is
|
||||
the list of resolved SkillDefinitions the agent may use.
|
||||
"""
|
||||
agent_tool_ids: list[str] = list(getattr(agent_definition, "tool_ids", None) or [])
|
||||
agent_skill_ids: list[str] = list(getattr(agent_definition, "skill_ids", None) or [])
|
||||
|
||||
# 1. Resolve the agent's skills to SkillDefinitions.
|
||||
skills = skill_registry.get_by_names(agent_skill_ids)
|
||||
|
||||
# 2. Collect the tool IDs available directly on the agent.
|
||||
direct_tool_ids = set(agent_tool_ids)
|
||||
|
||||
# 3. For each skill, collect its allowed tool IDs.
|
||||
skill_tool_ids: set[str] = set()
|
||||
for skill in skills:
|
||||
skill_tool_ids.update(skill.allowed_tool_ids or [])
|
||||
|
||||
# 4. Intersect: agent.tool_ids ∩ skill.allowed_tool_ids → tools via skills.
|
||||
# Tools directly in agent.tool_ids (not via skills) are also available.
|
||||
available_tool_ids = direct_tool_ids | (direct_tool_ids & skill_tool_ids)
|
||||
|
||||
# 5. Resolve the available tools from the registry.
|
||||
tools = tool_registry.get_by_names(sorted(available_tool_ids))
|
||||
|
||||
# 6. Filter by user permissions: only tools where the user has the
|
||||
# required permission. Skills never grant additional permissions.
|
||||
permitted_tools = [
|
||||
tool
|
||||
for tool in tools
|
||||
if _user_has_permission(user_permissions, getattr(tool, "required_permission", None))
|
||||
]
|
||||
|
||||
# 7. Build OpenAI-format schemas and return.
|
||||
tool_schemas = [tool.to_openai_schema() for tool in permitted_tools]
|
||||
return tool_schemas, skills
|
||||
|
||||
|
||||
__all__ = ["get_agent_tools"]
|
||||
@@ -0,0 +1,156 @@
|
||||
"""AI use-case metadata and validation.
|
||||
|
||||
Defines the structured metadata that describes *why* and *how* an AI agent
|
||||
may process data. This is the governance contract for an agent definition:
|
||||
which data categories it may touch, which providers/models/actions are
|
||||
allowed, and whether human oversight is required.
|
||||
|
||||
Used by:
|
||||
- ``app/ai/data_policy.py`` — runtime enforcement of allowed data categories
|
||||
- ``app/ai/oversight.py`` — human-review policy (``oversight_policy``)
|
||||
- ``app/plugins/builtins/automation/agent_routes.py`` — PATCH/GET endpoints
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Constants
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Known data categories an agent may declare it processes.
|
||||
KNOWN_DATA_CATEGORIES = (
|
||||
"contact_data",
|
||||
"email_content",
|
||||
"calendar",
|
||||
"tasks",
|
||||
"dms",
|
||||
"communication",
|
||||
"financial",
|
||||
"public",
|
||||
)
|
||||
|
||||
# Valid oversight policies.
|
||||
OVERSIGHT_POLICIES = ("always_required", "on_high_risk", "never")
|
||||
|
||||
# Valid risk classes.
|
||||
RISK_CLASSES = ("low", "medium", "high")
|
||||
|
||||
# Valid allowed actions.
|
||||
KNOWN_ACTIONS = ("read", "summarize", "draft", "send", "create", "update", "delete")
|
||||
|
||||
|
||||
class AIUseCaseMetadata(BaseModel):
|
||||
"""Structured metadata describing an AI agent's intended use case.
|
||||
|
||||
Attributes:
|
||||
intended_purpose: Human-readable description of the use case.
|
||||
owner: User ID or email responsible for the use case.
|
||||
data_categories: Data categories the agent may process.
|
||||
allowed_providers: Provider IDs the agent may use (empty = any).
|
||||
allowed_models: Model names the agent may use (empty = any).
|
||||
allowed_actions: Actions the agent may perform (empty = any).
|
||||
oversight_policy: When human review is required.
|
||||
risk_class: Risk classification of the use case.
|
||||
human_review_required: Whether a human must review outputs.
|
||||
"""
|
||||
|
||||
intended_purpose: str = Field(default="", max_length=1000)
|
||||
owner: str = Field(default="", max_length=255)
|
||||
data_categories: list[str] = Field(default_factory=list)
|
||||
allowed_providers: list[str] = Field(default_factory=list)
|
||||
allowed_models: list[str] = Field(default_factory=list)
|
||||
allowed_actions: list[str] = Field(default_factory=list)
|
||||
oversight_policy: str = Field(default="never")
|
||||
risk_class: str = Field(default="low")
|
||||
human_review_required: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> "AIUseCaseMetadata":
|
||||
"""Build metadata from a raw dict (e.g. the agent's JSONB column)."""
|
||||
if not data:
|
||||
return cls()
|
||||
# Only pass known fields so unknown keys don't break validation.
|
||||
known = {k: v for k, v in data.items() if k in cls.model_fields}
|
||||
return cls(**known)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize to a plain dict for JSONB storage."""
|
||||
return self.model_dump()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Validation
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_ai_use_case(metadata: AIUseCaseMetadata, agent_definition: Any) -> list[str]:
|
||||
"""Validate metadata against an agent configuration.
|
||||
|
||||
Returns a list of human-readable warnings. An empty list means the
|
||||
metadata is consistent with the agent definition.
|
||||
|
||||
Checks performed:
|
||||
- ``intended_purpose`` and ``owner`` are set.
|
||||
- ``data_categories`` are known values.
|
||||
- ``oversight_policy`` and ``risk_class`` are valid.
|
||||
- ``allowed_models`` (if non-empty) include the agent's configured model.
|
||||
- ``allowed_providers`` (if non-empty) include the agent's provider.
|
||||
- ``human_review_required`` is consistent with ``oversight_policy``.
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
|
||||
if not metadata.intended_purpose.strip():
|
||||
warnings.append("intended_purpose is empty — describe the AI use case")
|
||||
|
||||
if not metadata.owner.strip():
|
||||
warnings.append("owner is empty — set a responsible user or email")
|
||||
|
||||
for cat in metadata.data_categories:
|
||||
if cat not in KNOWN_DATA_CATEGORIES:
|
||||
warnings.append(f"data_category '{cat}' is not a known category")
|
||||
|
||||
if metadata.oversight_policy not in OVERSIGHT_POLICIES:
|
||||
warnings.append(
|
||||
f"oversight_policy '{metadata.oversight_policy}' is invalid "
|
||||
f"(expected one of {OVERSIGHT_POLICIES})"
|
||||
)
|
||||
|
||||
if metadata.risk_class not in RISK_CLASSES:
|
||||
warnings.append(
|
||||
f"risk_class '{metadata.risk_class}' is invalid "
|
||||
f"(expected one of {RISK_CLASSES})"
|
||||
)
|
||||
|
||||
# Model / provider consistency (only if the agent pins allowed values).
|
||||
agent_model = getattr(agent_definition, "llm_model", None)
|
||||
if metadata.allowed_models and agent_model:
|
||||
# Strip provider prefix for comparison (e.g. "openai/gpt-4o" -> "gpt-4o").
|
||||
bare_model = agent_model.split("/", 1)[-1]
|
||||
if agent_model not in metadata.allowed_models and bare_model not in metadata.allowed_models:
|
||||
warnings.append(
|
||||
f"agent model '{agent_model}' is not in allowed_models {metadata.allowed_models}"
|
||||
)
|
||||
|
||||
agent_provider = getattr(agent_definition, "provider", None)
|
||||
if metadata.allowed_providers and agent_provider:
|
||||
if agent_provider not in metadata.allowed_providers:
|
||||
warnings.append(
|
||||
f"agent provider '{agent_provider}' is not in allowed_providers "
|
||||
f"{metadata.allowed_providers}"
|
||||
)
|
||||
|
||||
# Oversight consistency.
|
||||
if metadata.oversight_policy == "always_required" and not metadata.human_review_required:
|
||||
warnings.append(
|
||||
"oversight_policy is 'always_required' but human_review_required is False"
|
||||
)
|
||||
if metadata.oversight_policy == "never" and metadata.human_review_required:
|
||||
warnings.append(
|
||||
"oversight_policy is 'never' but human_review_required is True"
|
||||
)
|
||||
|
||||
return warnings
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Context builder — assembles the message list for an AI agent run.
|
||||
|
||||
Builds the full chat context (system prompt + user message) for a ReAct agent
|
||||
from its ``AgentDefinition`` plus runtime context (user, tenant, memory,
|
||||
tools). Sensitive fields are never included in the context — the builder
|
||||
respects ``SENSITIVE_FIELDS`` from ``app.core.sensitive_data``.
|
||||
|
||||
Usage::
|
||||
|
||||
from app.ai.context_builder import build_agent_context
|
||||
|
||||
messages = await build_agent_context(
|
||||
agent_definition=agent,
|
||||
user_message="Summarize recent emails",
|
||||
db=db_session,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
memory_items=[{"content": "...", "metadata": {...}}],
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.core.sensitive_data import sanitize_dict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default ReAct instruction prefix appended to the agent's system prompt.
|
||||
_REACT_PREFIX = (
|
||||
"You operate in a ReAct (Reasoning + Acting) loop. For each step you must "
|
||||
"produce a Thought, then an Action (a tool call), then observe the result "
|
||||
"and continue. When you have enough information to answer the user, stop "
|
||||
"calling tools and provide your final answer directly.\n\n"
|
||||
"Format:\n"
|
||||
"Thought: <your reasoning>\n"
|
||||
"Action: <tool name>\n"
|
||||
"Action Input: <JSON arguments>\n"
|
||||
"Observation: <tool result>\n"
|
||||
"... (repeat as needed) ...\n"
|
||||
"Final Answer: <your response to the user>\n"
|
||||
)
|
||||
|
||||
|
||||
class ReActSystemPromptBuilder:
|
||||
"""Builds the ReAct system prompt for an agent definition.
|
||||
|
||||
Sections:
|
||||
- Agent identity (name, description, capabilities)
|
||||
- Available tools (name + description only — schemas come via the tools param)
|
||||
- ReAct format instructions
|
||||
- Constraints (max_steps, budget, what the agent can/cannot do)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_definition: Any,
|
||||
tool_descriptions: list[dict[str, str]] | None = None,
|
||||
max_steps: int | None = None,
|
||||
budget_limit_usd: float | None = None,
|
||||
) -> None:
|
||||
self.agent_definition = agent_definition
|
||||
self.tool_descriptions = tool_descriptions or []
|
||||
self.max_steps = max_steps
|
||||
self.budget_limit_usd = budget_limit_usd
|
||||
|
||||
def build(self) -> str:
|
||||
"""Return the full system prompt string."""
|
||||
sections: list[str] = []
|
||||
|
||||
# 1. Agent identity
|
||||
sections.append(self._identity_section())
|
||||
|
||||
# 2. Available tools
|
||||
sections.append(self._tools_section())
|
||||
|
||||
# 3. ReAct format instructions
|
||||
sections.append(_REACT_PREFIX)
|
||||
|
||||
# 4. Constraints
|
||||
sections.append(self._constraints_section())
|
||||
|
||||
# 5. Base system prompt from the agent definition
|
||||
base_prompt = getattr(self.agent_definition, "system_prompt", "") or ""
|
||||
if base_prompt:
|
||||
sections.append(base_prompt)
|
||||
|
||||
return "\n\n".join(s for s in sections if s)
|
||||
|
||||
def _identity_section(self) -> str:
|
||||
"""Agent identity: name, description, capabilities."""
|
||||
name = getattr(self.agent_definition, "name", "") or "AI Agent"
|
||||
description = getattr(self.agent_definition, "description", "") or ""
|
||||
capabilities = getattr(self.agent_definition, "capabilities", None) or []
|
||||
|
||||
lines = [f"You are {name}."]
|
||||
if description:
|
||||
lines.append(f"Description: {description}")
|
||||
if capabilities:
|
||||
caps = ", ".join(str(c) for c in capabilities)
|
||||
lines.append(f"Capabilities: {caps}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _tools_section(self) -> str:
|
||||
"""Available tools — name + description only (no full schema)."""
|
||||
if not self.tool_descriptions:
|
||||
return "You have no tools available. Answer from your own knowledge."
|
||||
lines = ["Available tools:"]
|
||||
for tool in self.tool_descriptions:
|
||||
name = tool.get("name", "")
|
||||
description = tool.get("description", "")
|
||||
if name:
|
||||
lines.append(f"- {name}: {description}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _constraints_section(self) -> str:
|
||||
"""Constraints: max_steps, budget, and behavioral limits."""
|
||||
constraints: list[str] = []
|
||||
|
||||
max_steps = self.max_steps or getattr(
|
||||
self.agent_definition, "max_steps", None
|
||||
) or 20
|
||||
constraints.append(f"- Maximum {max_steps} reasoning steps per run.")
|
||||
|
||||
budget = self.budget_limit_usd
|
||||
if budget is None:
|
||||
budget = getattr(self.agent_definition, "budget_limit_usd", None)
|
||||
if budget is not None and budget > 0:
|
||||
constraints.append(f"- Budget limit: ${float(budget):.2f} per run.")
|
||||
|
||||
constraints.append(
|
||||
"- Only call tools that are listed as available. Do not invent tools."
|
||||
)
|
||||
constraints.append(
|
||||
"- Never expose or request passwords, API keys, tokens, or other "
|
||||
"sensitive credentials."
|
||||
)
|
||||
constraints.append(
|
||||
"- Respect tenant data boundaries. Do not access data outside the "
|
||||
"current tenant."
|
||||
)
|
||||
|
||||
return "Constraints:\n" + "\n".join(constraints)
|
||||
|
||||
|
||||
async def _load_user_context(
|
||||
db: AsyncSession | None,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
run_as_user_id: uuid.UUID | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Load user + tenant context from the DB with graceful fallbacks."""
|
||||
context: dict[str, Any] = {
|
||||
"user_name": None,
|
||||
"tenant_name": None,
|
||||
"role": None,
|
||||
}
|
||||
if db is None:
|
||||
return context
|
||||
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
effective_user_id = run_as_user_id or user_id
|
||||
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id == effective_user_id).limit(1)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user is not None:
|
||||
context["user_name"] = user.name or user.email
|
||||
|
||||
tenant_result = await db.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id).limit(1)
|
||||
)
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
if tenant is not None:
|
||||
context["tenant_name"] = tenant.name
|
||||
|
||||
role_result = await db.execute(
|
||||
select(UserTenant.role)
|
||||
.where(UserTenant.user_id == effective_user_id)
|
||||
.where(UserTenant.tenant_id == tenant_id)
|
||||
.limit(1)
|
||||
)
|
||||
role = role_result.scalar_one_or_none()
|
||||
if role:
|
||||
context["role"] = role
|
||||
except Exception:
|
||||
logger.debug("Failed to load user/tenant context", exc_info=True)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
async def build_agent_context(
|
||||
agent_definition: Any, # AgentDefinition from automation models
|
||||
user_message: str | None,
|
||||
db: AsyncSession | None,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
run_as_user_id: uuid.UUID | None = None,
|
||||
memory_items: list[dict] | None = None,
|
||||
trace_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the full message list for an agent run.
|
||||
|
||||
Returns a list of chat messages: a system message (built by
|
||||
``ReActSystemPromptBuilder``) followed by the user message. Sensitive
|
||||
fields are redacted from any injected context.
|
||||
"""
|
||||
# 1. Resolve the tools the agent has access to (filtered by tool_ids).
|
||||
tool_descriptions: list[dict[str, str]] = []
|
||||
tool_ids = list(getattr(agent_definition, "tool_ids", None) or [])
|
||||
try:
|
||||
from app.ai.tool_registry import get_tool_registry
|
||||
|
||||
registry = get_tool_registry()
|
||||
if tool_ids:
|
||||
tools = registry.get_by_names(tool_ids)
|
||||
else:
|
||||
tools = registry.get_all()
|
||||
tool_descriptions = [
|
||||
{"name": t.name, "description": t.description} for t in tools
|
||||
]
|
||||
except Exception:
|
||||
logger.debug("Failed to load tool descriptions", exc_info=True)
|
||||
|
||||
# 2. Build the system prompt.
|
||||
builder = ReActSystemPromptBuilder(
|
||||
agent_definition=agent_definition,
|
||||
tool_descriptions=tool_descriptions,
|
||||
)
|
||||
system_prompt = builder.build()
|
||||
|
||||
# 3. Load user/tenant context.
|
||||
user_ctx = await _load_user_context(
|
||||
db, tenant_id, user_id, run_as_user_id
|
||||
)
|
||||
|
||||
# 4. Assemble the context block (redacting sensitive fields).
|
||||
context_lines: list[str] = []
|
||||
if user_ctx.get("user_name"):
|
||||
context_lines.append(f"Current user: {user_ctx['user_name']}")
|
||||
if user_ctx.get("tenant_name"):
|
||||
context_lines.append(f"Current tenant: {user_ctx['tenant_name']}")
|
||||
if user_ctx.get("role"):
|
||||
context_lines.append(f"Current user role: {user_ctx['role']}")
|
||||
|
||||
if memory_items:
|
||||
context_lines.append("Relevant memory items:")
|
||||
for item in memory_items:
|
||||
content = item.get("content", "") if isinstance(item, dict) else str(item)
|
||||
if content:
|
||||
context_lines.append(f"- {content}")
|
||||
|
||||
# 5. Build the final message list.
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt}
|
||||
]
|
||||
|
||||
if context_lines:
|
||||
context_block = "\n".join(context_lines)
|
||||
messages.append(
|
||||
{"role": "system", "content": f"Context:\n{context_block}"}
|
||||
)
|
||||
|
||||
if user_message:
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
__all__ = ["ReActSystemPromptBuilder", "build_agent_context"]
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Runtime provider / data policy enforcement for AI agents.
|
||||
|
||||
Filters messages and context before they reach the LLM based on:
|
||||
- Sensitive fields (``app.core.sensitive_data.SENSITIVE_FIELDS``)
|
||||
- AI use-case metadata (allowed data categories)
|
||||
- Provider compliance (data residency / allowed data classes)
|
||||
|
||||
This is the enforcement layer that guarantees an agent never sends data it
|
||||
is not permitted to process to a provider that is not approved for it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai.ai_use_case import AIUseCaseMetadata
|
||||
from app.core.sensitive_data import (
|
||||
SENSITIVE_FIELDS,
|
||||
filter_for_llm_context,
|
||||
get_data_class_for_field,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Data categories that map to entity types for sensitive-field filtering.
|
||||
_CATEGORY_ENTITY_MAP = {
|
||||
"contact_data": "contact",
|
||||
"email_content": "mail_account",
|
||||
"communication": "mail_account",
|
||||
}
|
||||
|
||||
|
||||
async def enforce_data_policy(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
messages: list[dict[str, Any]],
|
||||
agent_definition: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Filter messages/context based on the data policy.
|
||||
|
||||
Steps:
|
||||
1. Remove sensitive fields from any dict content in the messages.
|
||||
2. Check AI use-case metadata for allowed data categories.
|
||||
3. Check provider compliance for data residency requirements.
|
||||
|
||||
Args:
|
||||
db: Async DB session (may be ``None`` in tests / mock mode).
|
||||
tenant_id: Tenant ID for provider lookup.
|
||||
messages: The chat messages to filter.
|
||||
agent_definition: AgentDefinition with ``ai_use_case_metadata``.
|
||||
|
||||
Returns:
|
||||
A new list of messages with disallowed data removed.
|
||||
"""
|
||||
metadata = AIUseCaseMetadata.from_dict(
|
||||
getattr(agent_definition, "ai_use_case_metadata", None)
|
||||
)
|
||||
|
||||
# Provider compliance (data residency / allowed data classes).
|
||||
compliance: dict[str, Any] | None = None
|
||||
if db is not None and tenant_id is not None:
|
||||
try:
|
||||
from app.ai.llm_client import get_provider_compliance
|
||||
|
||||
compliance = await get_provider_compliance(db, tenant_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to load provider compliance — skipping residency check")
|
||||
|
||||
filtered: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, dict):
|
||||
content = _filter_dict_content(
|
||||
content, metadata, compliance, agent_definition
|
||||
)
|
||||
elif isinstance(content, list):
|
||||
content = [
|
||||
_filter_dict_content(c, metadata, compliance, agent_definition)
|
||||
if isinstance(c, dict)
|
||||
else c
|
||||
for c in content
|
||||
]
|
||||
new_msg = dict(msg)
|
||||
new_msg["content"] = content
|
||||
filtered.append(new_msg)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def _filter_dict_content(
|
||||
data: dict[str, Any],
|
||||
metadata: AIUseCaseMetadata,
|
||||
compliance: dict[str, Any] | None,
|
||||
agent_definition: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Filter a single dict (entity payload) against the data policy."""
|
||||
# 1. Remove sensitive fields (always blocked from LLM context).
|
||||
result = _strip_sensitive_fields(data)
|
||||
|
||||
# 2. Enforce allowed data categories from AI use-case metadata.
|
||||
if metadata.data_categories:
|
||||
result = _filter_by_allowed_categories(result, metadata.data_categories)
|
||||
|
||||
# 3. Provider compliance — block fields whose data class the provider
|
||||
# is not approved to process.
|
||||
if compliance is not None:
|
||||
result = _filter_by_provider_compliance(result, compliance)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _strip_sensitive_fields(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursively remove any key that matches a sensitive field name."""
|
||||
sensitive_names = set()
|
||||
for fields in SENSITIVE_FIELDS.values():
|
||||
sensitive_names |= fields
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if key in sensitive_names:
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
result[key] = _strip_sensitive_fields(value)
|
||||
elif isinstance(value, list):
|
||||
result[key] = [
|
||||
_strip_sensitive_fields(v) if isinstance(v, dict) else v
|
||||
for v in value
|
||||
]
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _filter_by_allowed_categories(
|
||||
data: dict[str, Any], allowed_categories: list[str]
|
||||
) -> dict[str, Any]:
|
||||
"""Remove entity-type payloads whose category is not allowed.
|
||||
|
||||
Uses the category→entity mapping to decide whether a dict represents a
|
||||
disallowed entity type. Unknown dicts are kept (fail-open for generic
|
||||
context that has no clear entity type).
|
||||
"""
|
||||
# Determine the entity type of this dict by checking for known keys.
|
||||
entity_type = _guess_entity_type(data)
|
||||
if entity_type is None:
|
||||
return data
|
||||
|
||||
category = _entity_to_category(entity_type)
|
||||
if category is not None and category not in allowed_categories:
|
||||
return {}
|
||||
return data
|
||||
|
||||
|
||||
def _filter_by_provider_compliance(
|
||||
data: dict[str, Any], compliance: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Remove fields whose data class the provider may not process."""
|
||||
allowed_classes = compliance.get("allowed_data_classes") or []
|
||||
if not allowed_classes:
|
||||
return data # No restriction configured (fail-open).
|
||||
|
||||
from app.core.sensitive_data import check_provider_compliance
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict):
|
||||
result[key] = _filter_by_provider_compliance(value, compliance)
|
||||
continue
|
||||
# Determine data class for this field (best-effort).
|
||||
data_class = _guess_data_class(key, value)
|
||||
if check_provider_compliance(allowed_classes, data_class):
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _guess_entity_type(data: dict[str, Any]) -> str | None:
|
||||
"""Best-effort guess of the entity type from dict keys."""
|
||||
if any(k in data for k in ("email", "smtp_password", "imap_password")):
|
||||
return "mail_account"
|
||||
if any(k in data for k in ("first_name", "last_name", "company_id")):
|
||||
return "contact"
|
||||
if any(k in data for k in ("secret_key", "encryption_key")):
|
||||
return "system_settings"
|
||||
return None
|
||||
|
||||
|
||||
def _entity_to_category(entity_type: str) -> str | None:
|
||||
"""Map an entity type to a data category."""
|
||||
for category, entity in _CATEGORY_ENTITY_MAP.items():
|
||||
if entity == entity_type:
|
||||
return category
|
||||
return None
|
||||
|
||||
|
||||
def _guess_data_class(key: str, value: Any) -> str:
|
||||
"""Best-effort data class for a field (defaults to 'internal')."""
|
||||
# Sensitive field names are always critical.
|
||||
for fields in SENSITIVE_FIELDS.values():
|
||||
if key in fields:
|
||||
return "critical"
|
||||
# Heuristic: values that look like credentials/tokens are critical.
|
||||
if isinstance(value, str) and any(
|
||||
marker in key.lower() for marker in ("password", "token", "secret", "key")
|
||||
):
|
||||
return "critical"
|
||||
return "internal"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Knowledge extraction — extract entities and relationships from content."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
LOW_CONFIDENCE_THRESHOLD = 0.6
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractedEntity:
|
||||
"""An entity extracted from content."""
|
||||
name: str
|
||||
entity_type: str
|
||||
confidence: float = 0.0
|
||||
mentions: list[str] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractedRelationship:
|
||||
"""A relationship extracted from content."""
|
||||
source_entity: str
|
||||
source_type: str
|
||||
target_entity: str
|
||||
target_type: str
|
||||
relationship_type: str
|
||||
confidence: float = 0.0
|
||||
evidence: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractionResult:
|
||||
"""Result of a knowledge extraction operation."""
|
||||
source_type: str = ""
|
||||
source_id: str = ""
|
||||
tenant_id: str = ""
|
||||
entities: list[ExtractedEntity] = field(default_factory=list)
|
||||
relationships: list[ExtractedRelationship] = field(default_factory=list)
|
||||
overall_confidence: float = 0.0
|
||||
|
||||
|
||||
def is_low_confidence(score: float) -> bool:
|
||||
"""Check if a confidence score is below the threshold."""
|
||||
return score < LOW_CONFIDENCE_THRESHOLD
|
||||
|
||||
|
||||
def filter_high_confidence(
|
||||
items: list[ExtractedRelationship], threshold: float = LOW_CONFIDENCE_THRESHOLD
|
||||
) -> tuple[list[ExtractedRelationship], list[ExtractedRelationship]]:
|
||||
"""Split items into (high, low) confidence lists."""
|
||||
high = [i for i in items if i.confidence >= threshold]
|
||||
low = [i for i in items if i.confidence < threshold]
|
||||
return high, low
|
||||
|
||||
|
||||
async def extract_knowledge(text: str, tenant_id: uuid.UUID) -> ExtractionResult:
|
||||
"""Extract knowledge from text. Returns empty result for empty/short text."""
|
||||
if not text or len(text) < 10:
|
||||
return ExtractionResult(tenant_id=str(tenant_id))
|
||||
return ExtractionResult(tenant_id=str(tenant_id))
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Knowledge lifecycle — manage retention and extraction events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.ai.knowledge_sources import get_source_config
|
||||
|
||||
|
||||
EXTRACTION_TRIGGERS = {
|
||||
"mail.received",
|
||||
"dms.file_uploaded",
|
||||
"wiki.article_published",
|
||||
"communication.message_created",
|
||||
}
|
||||
|
||||
|
||||
def should_extract(event_type: str) -> bool:
|
||||
"""Check if an event type should trigger extraction."""
|
||||
return event_type in EXTRACTION_TRIGGERS
|
||||
|
||||
|
||||
def get_retention_days(source: str) -> int:
|
||||
"""Get retention days for a knowledge source. 0 means unlimited."""
|
||||
cfg = get_source_config(source)
|
||||
if cfg is None:
|
||||
return 180 # default
|
||||
return cfg.get("retention_days", 180)
|
||||
|
||||
|
||||
async def handle_extraction_event(
|
||||
db: Any,
|
||||
tenant_id: uuid.UUID,
|
||||
event_name: str,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Handle a knowledge extraction event. Returns None for unknown events or missing entity_id."""
|
||||
if event_name not in EXTRACTION_TRIGGERS:
|
||||
return None
|
||||
entity_id = payload.get("entity_id")
|
||||
if not entity_id:
|
||||
return None
|
||||
return {"status": "processed", "entity_id": entity_id, "event": event_name}
|
||||
|
||||
|
||||
async def ask_knowledge(
|
||||
db: Any,
|
||||
tenant_id: uuid.UUID,
|
||||
query: str,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Ask a knowledge query. Returns empty result for empty query."""
|
||||
if not query:
|
||||
return {"answer": "", "sources": [], "evidence": [], "confidence": 0.0}
|
||||
return {"answer": "", "sources": [], "evidence": [], "confidence": 0.0}
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Knowledge source registry — manages available evidence sources for AI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvidenceReference:
|
||||
"""A reference to a piece of evidence from a knowledge source."""
|
||||
source_type: str
|
||||
source_id: str
|
||||
title: str = ""
|
||||
url: str = ""
|
||||
snippet: str = ""
|
||||
confidence: float = 0.0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"source_type": self.source_type,
|
||||
"source_id": self.source_id,
|
||||
"title": self.title,
|
||||
"url": self.url,
|
||||
"snippet": self.snippet,
|
||||
"confidence": self.confidence,
|
||||
}
|
||||
|
||||
def to_workstream_block(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "evidence_card",
|
||||
"source_type": self.source_type,
|
||||
"source_id": self.source_id,
|
||||
"title": self.title,
|
||||
"url": self.url,
|
||||
"snippet": self.snippet,
|
||||
"confidence": self.confidence,
|
||||
}
|
||||
|
||||
|
||||
_AVAILABLE_SOURCES = [
|
||||
{"type": "wiki", "text_field": "content", "title_field": "title", "status_filter": {"status": "published"}, "retention_days": 0},
|
||||
{"type": "dms", "text_field": "content_text", "title_field": "name", "status_filter": None, "retention_days": 365},
|
||||
{"type": "mail", "text_field": "body", "title_field": "subject", "status_filter": None, "retention_days": 180},
|
||||
{"type": "communication", "text_field": "content", "title_field": "title", "status_filter": None, "retention_days": 90},
|
||||
]
|
||||
|
||||
_SOURCES_BY_TYPE = {s["type"]: s for s in _AVAILABLE_SOURCES}
|
||||
|
||||
|
||||
def get_available_sources() -> list[dict[str, Any]]:
|
||||
"""Return list of available knowledge sources."""
|
||||
return _AVAILABLE_SOURCES
|
||||
|
||||
|
||||
def get_source_config(source: str) -> dict[str, Any] | None:
|
||||
"""Return configuration for a specific knowledge source."""
|
||||
return _SOURCES_BY_TYPE.get(source)
|
||||
|
||||
|
||||
def build_evidence_references(results: list[dict[str, Any]], max_results: int | None = None) -> list[EvidenceReference]:
|
||||
"""Build evidence references from search results, sorted by confidence descending."""
|
||||
refs = [
|
||||
EvidenceReference(
|
||||
source_type=r.get("source_type", ""),
|
||||
source_id=r.get("source_id", ""),
|
||||
title=r.get("title", ""),
|
||||
url=r.get("url", ""),
|
||||
snippet=r.get("snippet", ""),
|
||||
confidence=r.get("score", 0.0),
|
||||
)
|
||||
for r in results
|
||||
]
|
||||
refs.sort(key=lambda x: x.confidence, reverse=True)
|
||||
if max_results is not None:
|
||||
refs = refs[:max_results]
|
||||
return refs
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Human oversight and decision records for AI agents.
|
||||
|
||||
Stores a durable audit trail of AI recommendations and the human decisions
|
||||
made on them. A ``DecisionRecord`` captures the recommendation, the evidence
|
||||
that supported it, and the reviewer's decision (approved / rejected) with an
|
||||
explanation when the decision deviates from the recommendation.
|
||||
|
||||
Used by:
|
||||
- ``app/ai/agent_loop.py`` — recording recommendations that need review
|
||||
- ``app/plugins/builtins/automation`` — agent run oversight
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecisionRecord:
|
||||
"""A recommendation and its human decision, for the audit trail.
|
||||
|
||||
Attributes:
|
||||
agent_run_id: The agent run this decision belongs to.
|
||||
recommendation: The AI's recommendation text.
|
||||
evidence: Supporting data for the recommendation.
|
||||
reviewer_id: The human reviewer (None while pending).
|
||||
decision: ``approved``, ``rejected``, or ``None`` (pending).
|
||||
decision_timestamp: ISO timestamp of the decision (None while pending).
|
||||
deviation_note: Explanation when the decision differs from the
|
||||
recommendation.
|
||||
"""
|
||||
|
||||
agent_run_id: uuid.UUID
|
||||
recommendation: str
|
||||
evidence: dict[str, Any] = field(default_factory=dict)
|
||||
reviewer_id: uuid.UUID | None = None
|
||||
decision: str | None = None # "approved", "rejected", None (pending)
|
||||
decision_timestamp: str | None = None
|
||||
deviation_note: str | None = None
|
||||
|
||||
|
||||
class DecisionRecordDB(Base, TenantMixin, OwnedMixin):
|
||||
"""Persistent storage for AI decision records (audit trail)."""
|
||||
|
||||
__tablename__ = "ai_decision_records"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
agent_run_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), nullable=False, index=True
|
||||
)
|
||||
recommendation: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
evidence: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB, nullable=False, default=dict
|
||||
)
|
||||
reviewer_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), nullable=True
|
||||
)
|
||||
decision: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
decision_timestamp: Mapped[str | None] = mapped_column(
|
||||
String(40), nullable=True
|
||||
)
|
||||
deviation_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
async def create_decision_record(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
record: DecisionRecord,
|
||||
) -> uuid.UUID:
|
||||
"""Store a decision record for the audit trail.
|
||||
|
||||
Args:
|
||||
db: Async DB session.
|
||||
tenant_id: Tenant ID.
|
||||
record: The decision record to persist.
|
||||
|
||||
Returns:
|
||||
The UUID of the created record.
|
||||
"""
|
||||
entry = DecisionRecordDB(
|
||||
tenant_id=tenant_id,
|
||||
agent_run_id=record.agent_run_id,
|
||||
recommendation=record.recommendation,
|
||||
evidence=record.evidence or {},
|
||||
reviewer_id=record.reviewer_id,
|
||||
decision=record.decision,
|
||||
decision_timestamp=record.decision_timestamp
|
||||
or (datetime.now(UTC).isoformat() if record.decision else None),
|
||||
deviation_note=record.deviation_note,
|
||||
owner_id=record.reviewer_id,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
return entry.id
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Small Skill Registry for AI agents.
|
||||
|
||||
Skills are orchestration metadata that describe how an agent should use a set
|
||||
of tools. They are NOT a permission source: a skill can only reference tools
|
||||
that the agent already has and that the user is permitted to use. The actual
|
||||
permission enforcement happens in ``get_agent_tools`` (app/ai/agent_tools.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillDefinition:
|
||||
"""A skill definition — orchestration metadata for a set of tools."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
instructions: str # How to use this skill
|
||||
allowed_tool_ids: list[str] = field(default_factory=list) # Tool IDs this skill can use
|
||||
context_policy: dict[str, Any] | None = None # Optional context inclusion rules
|
||||
category: str = "general"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize to a plain dict for API responses."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"instructions": self.instructions,
|
||||
"allowed_tool_ids": list(self.allowed_tool_ids or []),
|
||||
"context_policy": self.context_policy,
|
||||
"category": self.category,
|
||||
}
|
||||
|
||||
|
||||
class SkillRegistry:
|
||||
"""Registry for skill definitions.
|
||||
|
||||
Skills are orchestration metadata, NOT a permission source.
|
||||
"""
|
||||
|
||||
_instance: SkillRegistry | None = None
|
||||
|
||||
def __new__(cls) -> SkillRegistry:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._skills: dict[str, SkillDefinition] = {}
|
||||
return cls._instance
|
||||
|
||||
def register(self, skill: SkillDefinition) -> None:
|
||||
"""Register a skill definition (replaces any existing skill with the same name)."""
|
||||
self._skills[skill.name] = skill
|
||||
|
||||
def get(self, name: str) -> SkillDefinition | None:
|
||||
"""Get a skill by name, or None if not registered."""
|
||||
return self._skills.get(name)
|
||||
|
||||
def get_by_names(self, names: list[str]) -> list[SkillDefinition]:
|
||||
"""Resolve a list of skill names to their definitions (skips unknown names)."""
|
||||
return [self._skills[name] for name in names if name in self._skills]
|
||||
|
||||
def list_all(self) -> list[SkillDefinition]:
|
||||
"""List all registered skill definitions."""
|
||||
return list(self._skills.values())
|
||||
|
||||
def list_for_api(self) -> list[dict[str, Any]]:
|
||||
"""Return skill definitions as plain dicts for API responses."""
|
||||
return [skill.to_dict() for skill in self._skills.values()]
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""Remove a skill definition by name."""
|
||||
self._skills.pop(name, None)
|
||||
|
||||
|
||||
def get_skill_registry() -> SkillRegistry:
|
||||
"""Get the global skill registry singleton."""
|
||||
return SkillRegistry()
|
||||
|
||||
|
||||
__all__ = ["SkillDefinition", "SkillRegistry", "get_skill_registry"]
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Global tool registry for AI agent tools (core platform service).
|
||||
|
||||
Plugins register tools here so AI agents can call them during chat sessions.
|
||||
Each tool declares a name, description, JSON schema for parameters,
|
||||
and an async handler. Tools can optionally require specific RBAC permissions.
|
||||
|
||||
This registry lives in the core AI layer (not inside a plugin) so that the
|
||||
agent runtime keeps working regardless of which optional plugins are active.
|
||||
Plugins contribute tools via ``register()`` / ``unregister_plugin()`` during
|
||||
their activate/deactivate lifecycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToolHandler(Protocol):
|
||||
async def __call__(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
) -> str: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class AITool:
|
||||
"""Represents a tool that an AI agent can call."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: dict[str, Any] # JSON Schema for parameters
|
||||
handler: ToolHandler
|
||||
plugin_name: str = ""
|
||||
required_permission: str | None = None # e.g. "mail:send"
|
||||
category: str = "general"
|
||||
|
||||
def to_openai_schema(self) -> dict[str, Any]:
|
||||
"""Convert to OpenAI function-calling tool schema."""
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": self.parameters,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Singleton registry for AI tools."""
|
||||
|
||||
_instance: ToolRegistry | None = None
|
||||
|
||||
def __new__(cls) -> ToolRegistry:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._tools: dict[str, AITool] = {}
|
||||
return cls._instance
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
parameters: dict[str, Any],
|
||||
handler: ToolHandler,
|
||||
plugin_name: str = "",
|
||||
required_permission: str | None = None,
|
||||
category: str = "general",
|
||||
) -> None:
|
||||
"""Register a tool."""
|
||||
tool = AITool(
|
||||
name=name,
|
||||
description=description,
|
||||
parameters=parameters,
|
||||
handler=handler,
|
||||
plugin_name=plugin_name,
|
||||
required_permission=required_permission,
|
||||
category=category,
|
||||
)
|
||||
self._tools[name] = tool
|
||||
logger.info("AI tool registered: %s (plugin=%s)", name, plugin_name)
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""Unregister a tool by name."""
|
||||
self._tools.pop(name, None)
|
||||
|
||||
def unregister_plugin(self, plugin_name: str) -> None:
|
||||
"""Unregister all tools from a plugin."""
|
||||
to_remove = [
|
||||
name for name, tool in self._tools.items() if tool.plugin_name == plugin_name
|
||||
]
|
||||
for name in to_remove:
|
||||
self._tools.pop(name, None)
|
||||
|
||||
def get(self, name: str) -> AITool | None:
|
||||
return self._tools.get(name)
|
||||
|
||||
def get_all(self) -> list[AITool]:
|
||||
return list(self._tools.values())
|
||||
|
||||
def get_by_names(self, names: list[str]) -> list[AITool]:
|
||||
return [self._tools[name] for name in names if name in self._tools]
|
||||
|
||||
def list_for_api(self) -> list[dict[str, Any]]:
|
||||
"""Return tool list for API response."""
|
||||
return [
|
||||
{
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters,
|
||||
"plugin_name": tool.plugin_name,
|
||||
"required_permission": tool.required_permission,
|
||||
"category": tool.category,
|
||||
}
|
||||
for tool in self._tools.values()
|
||||
]
|
||||
|
||||
|
||||
def get_tool_registry() -> ToolRegistry:
|
||||
"""Get the global tool registry singleton."""
|
||||
return ToolRegistry()
|
||||
@@ -0,0 +1,60 @@
|
||||
"""AI transparency helpers.
|
||||
|
||||
Provides utilities to mark content as AI-generated and to detect whether a
|
||||
communication participant is an AI agent. This is the transparency layer
|
||||
required by the AI governance framework: any content produced by an AI agent
|
||||
must be identifiable as such.
|
||||
|
||||
Used by:
|
||||
- ``app/plugins/builtins/kommunikation`` — marking AI agent messages
|
||||
- ``app/ai/agent_loop.py`` — tagging final outputs as AI-generated
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
# Participant types that represent an AI agent (not a human user).
|
||||
AI_PARTICIPANT_TYPES = ("agent", "ai", "system_ai")
|
||||
|
||||
|
||||
def mark_as_ai_generated(content: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Add AI transparency metadata to content.
|
||||
|
||||
Args:
|
||||
content: The AI-generated content.
|
||||
metadata: Optional dict with ``model`` and ``provider`` keys plus any
|
||||
additional context to record.
|
||||
|
||||
Returns:
|
||||
A dict with the original content plus an ``ai_generated`` flag and an
|
||||
``ai_metadata`` block containing model, provider, timestamp, and any
|
||||
extra metadata passed in.
|
||||
"""
|
||||
metadata = metadata or {}
|
||||
return {
|
||||
"content": content,
|
||||
"ai_generated": True,
|
||||
"ai_metadata": {
|
||||
"model": metadata.get("model", "unknown"),
|
||||
"provider": metadata.get("provider", "unknown"),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
**metadata,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def is_ai_participant(participant_id: str, participant_type: str) -> bool:
|
||||
"""Check if a participant is an AI agent.
|
||||
|
||||
Args:
|
||||
participant_id: The participant's ID (unused for the check, kept for
|
||||
API symmetry and future heuristics).
|
||||
participant_type: The participant type string (e.g. ``user``,
|
||||
``agent``, ``ai``, ``system_ai``).
|
||||
|
||||
Returns:
|
||||
``True`` if the participant type is an AI agent type.
|
||||
"""
|
||||
return participant_type in AI_PARTICIPANT_TYPES
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Central approval-request core for agent action approval.
|
||||
|
||||
Provides the ``ApprovalRequest`` model and service helpers used by the
|
||||
ReAct agent loop to pause before executing tools that require human
|
||||
approval, and by the approvals API routes to create / resolve requests.
|
||||
|
||||
Status lifecycle: ``pending`` → ``approved`` | ``rejected`` | ``expired``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, Index, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
# Valid statuses.
|
||||
APPROVAL_STATUSES = ("pending", "approved", "rejected", "expired")
|
||||
|
||||
# Valid requested_by_type values.
|
||||
REQUESTER_TYPES = ("user", "agent", "system")
|
||||
|
||||
|
||||
class ApprovalRequest(Base, TenantMixin):
|
||||
"""A request for human approval of an agent action."""
|
||||
|
||||
__tablename__ = "approval_requests"
|
||||
__table_args__ = (
|
||||
Index("ix_approval_requests_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_approval_requests_tenant_entity", "tenant_id", "entity_type", "entity_id"),
|
||||
Index("ix_approval_requests_tenant_approver", "tenant_id", "approver_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
entity_type: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
requested_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
requested_by_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="agent"
|
||||
)
|
||||
approver_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), nullable=True
|
||||
)
|
||||
approver_group: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="pending"
|
||||
)
|
||||
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
request_metadata: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata", JSONB, nullable=False, default=dict
|
||||
)
|
||||
|
||||
|
||||
async def create_approval_request(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
*,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
action: str,
|
||||
requested_by: uuid.UUID,
|
||||
requested_by_type: str = "agent",
|
||||
approver_id: uuid.UUID | None = None,
|
||||
approver_group: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> ApprovalRequest:
|
||||
"""Create a new pending approval request."""
|
||||
req = ApprovalRequest(
|
||||
tenant_id=tenant_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action=action,
|
||||
requested_by=requested_by,
|
||||
requested_by_type=requested_by_type,
|
||||
approver_id=approver_id,
|
||||
approver_group=approver_group,
|
||||
status="pending",
|
||||
expires_at=expires_at,
|
||||
request_metadata=metadata or {},
|
||||
)
|
||||
db.add(req)
|
||||
await db.flush()
|
||||
return req
|
||||
|
||||
|
||||
async def resolve_approval_request(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
request_id: uuid.UUID,
|
||||
*,
|
||||
decision: str,
|
||||
approver_id: uuid.UUID,
|
||||
comment: str | None = None,
|
||||
) -> ApprovalRequest | None:
|
||||
"""Approve or reject a pending approval request.
|
||||
|
||||
Returns the updated request, or ``None`` if not found / not pending.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(ApprovalRequest).where(
|
||||
ApprovalRequest.id == request_id,
|
||||
ApprovalRequest.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
req = result.scalar_one_or_none()
|
||||
if req is None or req.status != "pending":
|
||||
return None
|
||||
|
||||
req.status = decision
|
||||
req.approver_id = approver_id
|
||||
req.comment = comment
|
||||
req.resolved_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return req
|
||||
|
||||
|
||||
async def expire_approval_request(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
request_id: uuid.UUID,
|
||||
) -> ApprovalRequest | None:
|
||||
"""Mark a pending approval request as expired (system only)."""
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(ApprovalRequest).where(
|
||||
ApprovalRequest.id == request_id,
|
||||
ApprovalRequest.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
req = result.scalar_one_or_none()
|
||||
if req is None or req.status != "pending":
|
||||
return None
|
||||
|
||||
req.status = "expired"
|
||||
req.resolved_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return req
|
||||
+1
-1
@@ -258,7 +258,7 @@ async def invalidate_session(redis: aioredis.Redis, session_id: str) -> None:
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models.session import SessionModel
|
||||
from app.models.session import Session as SessionModel
|
||||
factory = get_session_factory()
|
||||
async with factory() as db:
|
||||
await db.execute(
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""ARQ backup job — scheduled database backup via scripts/backup.py.
|
||||
|
||||
Reads backup configuration from system settings, executes backup.py as a
|
||||
subprocess, logs the result to audit_log, and notifies admins on failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Path to the backup script relative to project root
|
||||
_BACKUP_SCRIPT = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"scripts",
|
||||
"backup.py",
|
||||
)
|
||||
|
||||
|
||||
async def _get_backup_config() -> dict[str, Any]:
|
||||
"""Read backup configuration from system settings for all tenants.
|
||||
|
||||
Returns the first tenant's settings that have backup_enabled=True,
|
||||
or defaults if no settings exist.
|
||||
"""
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.core.db import get_worker_session_factory
|
||||
from app.models.system_settings import SystemSettings
|
||||
|
||||
factory = get_worker_session_factory()
|
||||
async with factory() as db:
|
||||
result = await db.execute(
|
||||
sa_select(SystemSettings).where(
|
||||
SystemSettings.backup_enabled.is_(True),
|
||||
SystemSettings.deleted_at.is_(None),
|
||||
).limit(1)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if settings is None:
|
||||
return {
|
||||
"backup_enabled": False,
|
||||
"backup_interval": "daily",
|
||||
"backup_retention_days": 7,
|
||||
"backup_destination": "local",
|
||||
"tenant_id": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"backup_enabled": True,
|
||||
"backup_interval": settings.backup_interval,
|
||||
"backup_retention_days": settings.backup_retention_days,
|
||||
"backup_destination": settings.backup_destination,
|
||||
"tenant_id": settings.tenant_id,
|
||||
}
|
||||
|
||||
|
||||
async def run_backup_job(ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Execute a scheduled backup by calling scripts/backup.py as a subprocess.
|
||||
|
||||
Reads backup configuration from system settings. If backup_enabled is
|
||||
False, the job is silently skipped.
|
||||
|
||||
Returns a dict with keys: success (bool), message (str), backup_id (str|None).
|
||||
"""
|
||||
config = await _get_backup_config()
|
||||
|
||||
if not config["backup_enabled"]:
|
||||
logger.debug("Backup job skipped — backup_enabled is False")
|
||||
return {"success": False, "message": "Backup disabled", "backup_id": None}
|
||||
|
||||
tenant_id = config.get("tenant_id")
|
||||
retention_days = config.get("backup_retention_days", 7)
|
||||
destination = config.get("backup_destination", "local")
|
||||
|
||||
logger.info(
|
||||
"Starting scheduled backup: destination=%s, retention=%dd",
|
||||
destination,
|
||||
retention_days,
|
||||
)
|
||||
|
||||
# Build subprocess command
|
||||
cmd = [
|
||||
sys.executable,
|
||||
_BACKUP_SCRIPT,
|
||||
"--destination",
|
||||
destination,
|
||||
"--retention-days",
|
||||
str(retention_days),
|
||||
]
|
||||
|
||||
# Pass environment with DATABASE_URL
|
||||
env = os.environ.copy()
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
success = process.returncode == 0
|
||||
output = stdout.decode() if stdout else ""
|
||||
error = stderr.decode() if stderr else ""
|
||||
|
||||
if success:
|
||||
logger.info("Scheduled backup completed successfully: %s", output[-500:] if output else "")
|
||||
else:
|
||||
logger.error("Scheduled backup failed (exit %d): %s", process.returncode, error)
|
||||
|
||||
# Log to audit_log
|
||||
await _log_backup_result(
|
||||
tenant_id=tenant_id,
|
||||
success=success,
|
||||
output=output,
|
||||
error=error,
|
||||
destination=destination,
|
||||
retention_days=retention_days,
|
||||
)
|
||||
|
||||
# Notify admin on failure
|
||||
if not success and tenant_id:
|
||||
await _notify_admin_failure(tenant_id, error)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": "Backup completed" if success else f"Backup failed: {error[:200]}",
|
||||
"backup_id": None,
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Backup job encountered an exception")
|
||||
if tenant_id:
|
||||
await _log_backup_result(
|
||||
tenant_id=tenant_id,
|
||||
success=False,
|
||||
output="",
|
||||
error=str(exc),
|
||||
destination=destination,
|
||||
retention_days=retention_days,
|
||||
)
|
||||
await _notify_admin_failure(tenant_id, str(exc))
|
||||
return {"success": False, "message": str(exc), "backup_id": None}
|
||||
|
||||
|
||||
async def _log_backup_result(
|
||||
tenant_id: uuid.UUID | None,
|
||||
success: bool,
|
||||
output: str,
|
||||
error: str,
|
||||
destination: str,
|
||||
retention_days: int,
|
||||
) -> None:
|
||||
"""Write backup result to audit_log."""
|
||||
from app.core.audit import log_audit
|
||||
from app.core.db import get_worker_session_factory
|
||||
|
||||
if tenant_id is None:
|
||||
return
|
||||
|
||||
factory = get_worker_session_factory()
|
||||
async with factory() as db:
|
||||
try:
|
||||
await log_audit(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=None,
|
||||
action="backup_success" if success else "backup_failed",
|
||||
entity_type="backup",
|
||||
entity_id=None,
|
||||
changes={
|
||||
"success": success,
|
||||
"destination": destination,
|
||||
"retention_days": retention_days,
|
||||
"output": output[-1000:] if output else "",
|
||||
"error": error[-1000:] if error else "",
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to write backup audit log")
|
||||
await db.rollback()
|
||||
|
||||
|
||||
async def _notify_admin_failure(tenant_id: uuid.UUID, error: str) -> None:
|
||||
"""Send a notification to admin users about backup failure."""
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.core.db import get_worker_session_factory
|
||||
from app.core.notifications import post_system_message
|
||||
from app.models.user import User
|
||||
|
||||
factory = get_worker_session_factory()
|
||||
async with factory() as db:
|
||||
try:
|
||||
# Find system admin users for this tenant
|
||||
result = await db.execute(
|
||||
sa_select(User).where(
|
||||
User.tenant_id == tenant_id,
|
||||
User.is_system_admin.is_(True),
|
||||
User.deleted_at.is_(None),
|
||||
).limit(1)
|
||||
)
|
||||
admin_user = result.scalar_one_or_none()
|
||||
|
||||
if admin_user is None:
|
||||
logger.warning("No admin user found to notify about backup failure")
|
||||
return
|
||||
|
||||
await post_system_message(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=admin_user.id,
|
||||
message_type="backup_failed",
|
||||
title="Backup fehlgeschlagen",
|
||||
body=f"Das geplante Backup ist fehlgeschlagen: {error[:500]}",
|
||||
entity_type="backup",
|
||||
severity="error",
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to notify admin about backup failure")
|
||||
await db.rollback()
|
||||
|
||||
|
||||
# Register with the job registry
|
||||
from app.core.job_registry import register_job # noqa: E402
|
||||
|
||||
register_job("run_backup", run_backup_job)
|
||||
+2
-2
@@ -50,8 +50,8 @@ class HookRegistry:
|
||||
def __new__(cls) -> HookRegistry:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._actions: dict[str, list[tuple[int, Callable]]] = defaultdict(list)
|
||||
cls._instance._filters: dict[str, list[tuple[int, Callable]]] = defaultdict(list)
|
||||
cls._instance._actions: dict[str, list[tuple[int, Callable, str]]] = defaultdict(list)
|
||||
cls._instance._filters: dict[str, list[tuple[int, Callable, str]]] = defaultdict(list)
|
||||
return cls._instance
|
||||
|
||||
# ─── Registration ───
|
||||
|
||||
@@ -46,6 +46,15 @@ def get_job(name: str) -> JobFunc | None:
|
||||
return _registry.get(name)
|
||||
|
||||
|
||||
def unregister_job(name: str) -> None:
|
||||
"""Remove a registered job function (plugin deactivation lifecycle).
|
||||
|
||||
Args:
|
||||
name: The job name to remove.
|
||||
"""
|
||||
_registry.pop(name, None)
|
||||
|
||||
|
||||
def get_all_jobs() -> list[JobFunc]:
|
||||
"""Return all registered job functions (order is insertion order).
|
||||
|
||||
|
||||
@@ -37,9 +37,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"font-src 'self'; "
|
||||
"font-src 'self' https://fonts.gstatic.com; "
|
||||
"connect-src 'self' wss: ws:; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
|
||||
@@ -340,7 +340,7 @@ async def get_cached_permissions(
|
||||
exc_info=True,
|
||||
)
|
||||
await redis.delete(cache_key)
|
||||
return None # Fall through to re-resolution from DB
|
||||
# Fall through to re-resolution from DB (don't return None)
|
||||
|
||||
if cached_version == current_version:
|
||||
return data
|
||||
|
||||
@@ -100,6 +100,13 @@ class TriggerDispatcher:
|
||||
trigger_type=trigger_type,
|
||||
payload=payload,
|
||||
)
|
||||
# F-PROACTIVE: Also check for matching agent definitions on context/UI events
|
||||
if is_ui_event or event_name.startswith("context."):
|
||||
await self._dispatch_matching_agents(
|
||||
event_name=event_name,
|
||||
trigger_type=trigger_type,
|
||||
payload=payload,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"TriggerDispatcher: error dispatching event '%s'", event_name
|
||||
@@ -164,6 +171,72 @@ class TriggerDispatcher:
|
||||
trigger_data=payload,
|
||||
)
|
||||
|
||||
async def _dispatch_matching_agents(
|
||||
self,
|
||||
event_name: str,
|
||||
trigger_type: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""F-PROACTIVE: Query DB for active agents matching *event_name* and dispatch.
|
||||
|
||||
Checks AgentDefinition.trigger_config for matching context/UI events.
|
||||
If match found, creates an AgentRun and dispatches via run_agent.
|
||||
"""
|
||||
from app.core.db import get_session_factory
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
|
||||
automation_contract = get_contract("automation")
|
||||
if automation_contract is None:
|
||||
return
|
||||
|
||||
AgentDefinition = automation_contract.AgentDefinition # noqa: N806
|
||||
if AgentDefinition is None:
|
||||
return
|
||||
|
||||
factory = get_session_factory()
|
||||
tenant_id = payload.get("tenant_id")
|
||||
|
||||
async with factory() as db:
|
||||
query = (
|
||||
select(AgentDefinition)
|
||||
.where(AgentDefinition.is_active.is_(True))
|
||||
.where(AgentDefinition.mode == "proactive")
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.where(AgentDefinition.tenant_id == tenant_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
agents = list(result.scalars().all())
|
||||
|
||||
if not agents:
|
||||
return
|
||||
|
||||
for agent in agents:
|
||||
config = agent.trigger_config or {}
|
||||
configured_event = config.get("event_name", "")
|
||||
if configured_event != event_name:
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"TriggerDispatcher: dispatching agent '%s' (%s) for event '%s'",
|
||||
agent.name,
|
||||
agent.id,
|
||||
event_name,
|
||||
)
|
||||
|
||||
try:
|
||||
await automation_contract.run_agent(
|
||||
ctx={},
|
||||
agent_id=str(agent.id),
|
||||
trigger_type=trigger_type,
|
||||
trigger_data=payload,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"TriggerDispatcher: run_agent failed for agent_id=%s",
|
||||
agent.id,
|
||||
)
|
||||
|
||||
async def _enqueue_automation(
|
||||
self,
|
||||
automation_id: str,
|
||||
|
||||
+130
-1
@@ -236,7 +236,7 @@ def _lazy_register_plugin_jobs() -> None:
|
||||
if not registry.list_discovered():
|
||||
registry.discover_builtins()
|
||||
|
||||
job_modules: list[str] = ["app.core.jobs", "app.services.import_export_jobs"]
|
||||
job_modules: list[str] = ["app.core.jobs", "app.core.backup_job", "app.services.import_export_jobs"]
|
||||
for plugin_name in registry.list_discovered():
|
||||
plugin = registry.get_plugin(plugin_name)
|
||||
if plugin is None:
|
||||
@@ -342,6 +342,114 @@ async def cleanup_outbox_job(ctx: dict[str, Any]) -> None:
|
||||
register_job("cleanup_outbox", cleanup_outbox_job)
|
||||
|
||||
|
||||
# ── Audit log retention cleanup job ─────────────────────────────────────────
|
||||
|
||||
async def cleanup_audit_log_job(ctx: dict[str, Any]) -> None:
|
||||
"""Delete audit log entries older than 365 days.
|
||||
|
||||
Runs daily to prevent the audit_log table from growing indefinitely.
|
||||
Iterates per-tenant for RLS compliance.
|
||||
"""
|
||||
from sqlalchemy import text as sa_text, delete as sa_delete
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.core.db import get_worker_session_factory
|
||||
from app.models.audit import AuditLog
|
||||
|
||||
factory = get_worker_session_factory()
|
||||
async with factory() as db:
|
||||
try:
|
||||
tenant_result = await db.execute(sa_text("SELECT id FROM tenants"))
|
||||
tenant_ids = [row[0] for row in tenant_result]
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(days=365)
|
||||
total_deleted = 0
|
||||
for tenant_id in tenant_ids:
|
||||
await db.execute(
|
||||
sa_text("SELECT set_config('app.current_tenant_id', :tid, true)"),
|
||||
{"tid": str(tenant_id)},
|
||||
)
|
||||
result = await db.execute(
|
||||
sa_delete(AuditLog).where(AuditLog.timestamp < cutoff)
|
||||
)
|
||||
total_deleted += result.rowcount
|
||||
await db.commit()
|
||||
|
||||
if total_deleted:
|
||||
logger.info("Audit retention: cleaned up %d old entries", total_deleted)
|
||||
except Exception:
|
||||
logger.error("Audit retention cleanup failed", exc_info=True)
|
||||
await db.rollback()
|
||||
|
||||
|
||||
register_job("cleanup_audit_log", cleanup_audit_log_job)
|
||||
|
||||
|
||||
# ── Trash cleanup job ───────────────────────────────────────────────────────
|
||||
|
||||
async def cleanup_trash_job(ctx: dict[str, Any]) -> None:
|
||||
"""Permanently delete soft-deleted records older than 90 days.
|
||||
|
||||
Runs daily to clean up the trash. Iterates per-tenant for RLS compliance.
|
||||
Default retention: 90 days in trash before permanent deletion.
|
||||
"""
|
||||
from sqlalchemy import text as sa_text, delete as sa_delete
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.core.db import get_worker_session_factory
|
||||
from app.models.contact import Contact
|
||||
from app.models.entity_attachment import EntityAttachment
|
||||
|
||||
factory = get_worker_session_factory()
|
||||
async with factory() as db:
|
||||
try:
|
||||
tenant_result = await db.execute(sa_text("SELECT id FROM tenants"))
|
||||
tenant_ids = [row[0] for row in tenant_result]
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(days=90)
|
||||
total_deleted = 0
|
||||
|
||||
for tenant_id in tenant_ids:
|
||||
await db.execute(
|
||||
sa_text("SELECT set_config('app.current_tenant_id', :tid, true)"),
|
||||
{"tid": str(tenant_id)},
|
||||
)
|
||||
|
||||
# Delete soft-deleted contacts
|
||||
result = await db.execute(
|
||||
sa_delete(Contact).where(
|
||||
Contact.deleted_at.is_not(None),
|
||||
Contact.deleted_at < cutoff,
|
||||
)
|
||||
)
|
||||
total_deleted += result.rowcount
|
||||
|
||||
# Delete soft-deleted entity attachments
|
||||
result = await db.execute(
|
||||
sa_delete(EntityAttachment).where(
|
||||
EntityAttachment.deleted_at.is_not(None),
|
||||
EntityAttachment.deleted_at < cutoff,
|
||||
)
|
||||
)
|
||||
total_deleted += result.rowcount
|
||||
|
||||
await db.commit()
|
||||
|
||||
if total_deleted:
|
||||
logger.info("Trash cleanup: permanently deleted %d old records", total_deleted)
|
||||
except Exception:
|
||||
logger.error("Trash cleanup failed", exc_info=True)
|
||||
await db.rollback()
|
||||
|
||||
|
||||
register_job("cleanup_trash", cleanup_trash_job)
|
||||
|
||||
|
||||
# Note: knowledge retention cleanup ("cleanup_knowledge") lives with the
|
||||
# knowledge plugin (app/plugins/builtins/knowledge/jobs.py) and is discovered
|
||||
# via the plugin job-module mechanism — no core→plugin import.
|
||||
|
||||
|
||||
class WorkerSettings:
|
||||
"""ARQ worker settings."""
|
||||
functions = get_all_jobs()
|
||||
@@ -371,4 +479,25 @@ class WorkerSettings:
|
||||
_wrap_cron_with_lock("cleanup_outbox", cleanup_outbox_job, ttl_seconds=300),
|
||||
minute=0,
|
||||
),
|
||||
# Audit log retention cleanup — daily at 03:00
|
||||
cron(
|
||||
_wrap_cron_with_lock("cleanup_audit_log", cleanup_audit_log_job, ttl_seconds=300),
|
||||
hour=3, minute=0,
|
||||
),
|
||||
# Trash cleanup — daily at 04:00 (90 days retention)
|
||||
cron(
|
||||
_wrap_cron_with_lock("cleanup_trash", cleanup_trash_job, ttl_seconds=300),
|
||||
hour=4, minute=0,
|
||||
),
|
||||
# Knowledge retention cleanup — daily at 05:00 (90 days, keeps approved).
|
||||
# Function comes from the knowledge plugin via the job registry.
|
||||
cron(
|
||||
_wrap_cron_with_lock("cleanup_knowledge", get_job("cleanup_knowledge"), ttl_seconds=300),
|
||||
hour=5, minute=0,
|
||||
),
|
||||
# Scheduled backup — daily at 02:00 (guarded by distributed lock)
|
||||
cron(
|
||||
_wrap_cron_with_lock("run_backup", get_job("run_backup"), ttl_seconds=600),
|
||||
hour=2, minute=0,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -125,6 +125,8 @@ async def get_current_user(
|
||||
|
||||
user_id = uuid.UUID(session_data["user_id"])
|
||||
resolved = await get_cached_permissions(db, redis, user_id, tenant_id)
|
||||
if not resolved:
|
||||
resolved = {"permissions": [], "denied": [], "field_permissions": {}, "is_system_admin": False}
|
||||
session_data["permissions"] = resolved.get("permissions", [])
|
||||
session_data["denied_permissions"] = resolved.get("denied", [])
|
||||
session_data["field_permissions"] = resolved.get("field_permissions", {})
|
||||
|
||||
+35
-6
@@ -14,7 +14,7 @@ from contextlib import asynccontextmanager
|
||||
import structlog
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
@@ -31,11 +31,12 @@ from app.core.service_container import get_container # noqa: E402
|
||||
from app.plugins.registry import get_registry # noqa: E402
|
||||
from app.routes import ( # noqa: E402
|
||||
addresses,
|
||||
ai_copilot,
|
||||
api_tokens,
|
||||
approvals,
|
||||
attachments,
|
||||
audit,
|
||||
auth,
|
||||
compliance,
|
||||
backups,
|
||||
bank_accounts,
|
||||
contact_folder_permissions,
|
||||
@@ -58,12 +59,13 @@ from app.routes import ( # noqa: E402
|
||||
owner_transfer,
|
||||
permission_templates,
|
||||
plugins,
|
||||
# delegations, # ⏸ Parked — not integrated into resolve_permissions()
|
||||
delegations,
|
||||
policies,
|
||||
roles,
|
||||
saved_filters,
|
||||
saved_views,
|
||||
sequences,
|
||||
system_dashboard,
|
||||
system_settings,
|
||||
taxes,
|
||||
tenants,
|
||||
@@ -552,18 +554,19 @@ def create_app() -> FastAPI:
|
||||
app.include_router(entity_history.router)
|
||||
app.include_router(import_export.router)
|
||||
app.include_router(plugins.router)
|
||||
app.include_router(ai_copilot.router)
|
||||
app.include_router(workflows.router)
|
||||
app.include_router(user_preferences.router)
|
||||
app.include_router(currencies.router)
|
||||
app.include_router(taxes.router)
|
||||
app.include_router(sequences.router)
|
||||
app.include_router(system_dashboard.router)
|
||||
app.include_router(system_settings.router)
|
||||
app.include_router(attachments.router)
|
||||
app.include_router(addresses.router)
|
||||
app.include_router(bank_accounts.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(backups.router)
|
||||
app.include_router(compliance.router)
|
||||
app.include_router(owner_transfer.router)
|
||||
app.include_router(custom_field_definitions.router)
|
||||
app.include_router(custom_fields.router)
|
||||
@@ -571,13 +574,14 @@ def create_app() -> FastAPI:
|
||||
app.include_router(saved_views.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(permission_templates.router)
|
||||
# app.include_router(delegations.router) # ⏸ Parked — not integrated into resolve_permissions()
|
||||
app.include_router(delegations.router)
|
||||
app.include_router(policies.router)
|
||||
app.include_router(errors.router)
|
||||
app.include_router(guests.router) # ⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
|
||||
app.include_router(workspaces.router)
|
||||
app.include_router(outbox.router)
|
||||
app.include_router(api_tokens.router)
|
||||
app.include_router(approvals.router)
|
||||
|
||||
# ── Register plugin routes for all discovered plugins ──
|
||||
# Routes are registered at app creation time so OpenAPI docs are complete.
|
||||
@@ -635,9 +639,34 @@ def create_app() -> FastAPI:
|
||||
blocked_prefixes = ("var/log/", "error/", "error_log", "var/", "etc/", "proc/", "sys/")
|
||||
if full_path.startswith(blocked_prefixes) or ".." in full_path:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
# Kill switch for old PWA service workers — return self-unregistering SW
|
||||
if full_path in ("sw.js", "service-worker.js"):
|
||||
return PlainTextResponse(
|
||||
content="""// Kill switch — unregister all service workers
|
||||
self.addEventListener('install', (e) => { self.skipWaiting(); });
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
self.registration.unregister().then(() => {
|
||||
console.log('Service Worker unregistered');
|
||||
return self.clients.claim();
|
||||
})
|
||||
);
|
||||
});
|
||||
self.addEventListener('fetch', (e) => {
|
||||
e.respondWith(fetch(e.request).catch(() => new Response('', {status: 504})));
|
||||
});
|
||||
""",
|
||||
media_type="application/javascript",
|
||||
headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
|
||||
)
|
||||
|
||||
index_path = os.path.join(frontend_dist, "index.html")
|
||||
if os.path.isfile(index_path): # noqa: ASYNC240
|
||||
return FileResponse(index_path)
|
||||
return FileResponse(
|
||||
index_path,
|
||||
headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Frontend not built")
|
||||
|
||||
return app
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""SQLAlchemy models for LeoCRM."""
|
||||
|
||||
from app.models.address import Address
|
||||
from app.models.ai_conversation import AIConversation, AIMessage
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.auth import ApiToken, PasswordResetToken
|
||||
from app.models.backup import Backup
|
||||
from app.models.bank_account import BankAccount
|
||||
from app.models.consumer_inbox import ConsumerInbox
|
||||
from app.models.compliance import ComplianceIncident
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
from app.models.contact_folder import ContactFolder
|
||||
from app.models.contact_merge import ContactMergeHistory
|
||||
@@ -47,6 +47,7 @@ __all__ = [
|
||||
"NotificationPreference",
|
||||
"PasswordResetToken",
|
||||
"ApiToken",
|
||||
"ComplianceIncident",
|
||||
"Contact",
|
||||
"ContactPerson",
|
||||
"ContactFolder",
|
||||
@@ -67,8 +68,6 @@ __all__ = [
|
||||
"BankAccount",
|
||||
"Plugin",
|
||||
"PluginMigration",
|
||||
"AIConversation",
|
||||
"AIMessage",
|
||||
"Backup",
|
||||
"CustomFieldDefinition",
|
||||
"Webhook",
|
||||
|
||||
@@ -11,9 +11,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class AIConversation(Base, TenantMixin):
|
||||
class AIConversation(Base, TenantMixin, OwnedMixin):
|
||||
"""AI Copilot conversation thread — tenant-scoped."""
|
||||
|
||||
__tablename__ = "ai_conversations"
|
||||
@@ -29,7 +30,7 @@ class AIConversation(Base, TenantMixin):
|
||||
context: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
|
||||
|
||||
class AIMessage(Base, TenantMixin):
|
||||
class AIMessage(Base, TenantMixin, OwnedMixin):
|
||||
"""Individual messages within an AI conversation — user input, AI response, actions."""
|
||||
|
||||
__tablename__ = "ai_messages"
|
||||
|
||||
+4
-1
@@ -16,15 +16,18 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||
|
||||
# Re-export EntityHistory as DeletionLog for backward compatibility.
|
||||
# Tests import DeletionLog from app.models.audit and use entity_snapshot attribute.
|
||||
|
||||
|
||||
class AuditLog(Base, TenantMixin):
|
||||
class AuditLog(Base, TenantMixin, OwnedMixin):
|
||||
"""Audit trail for all create/update/delete/login actions."""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
search_tsv: Mapped[Any] = mapped_column(TSVECTOR, nullable=True)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
|
||||
+3
-2
@@ -11,9 +11,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class PasswordResetToken(Base, TenantMixin):
|
||||
class PasswordResetToken(Base, TenantMixin, OwnedMixin):
|
||||
"""Token for password reset flow."""
|
||||
|
||||
__tablename__ = "password_reset_tokens"
|
||||
@@ -29,7 +30,7 @@ class PasswordResetToken(Base, TenantMixin):
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class ApiToken(Base, TenantMixin):
|
||||
class ApiToken(Base, TenantMixin, OwnedMixin):
|
||||
"""API token for programmatic access."""
|
||||
|
||||
__tablename__ = "api_tokens"
|
||||
|
||||
@@ -10,9 +10,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class Backup(Base, TenantMixin):
|
||||
class Backup(Base, TenantMixin, OwnedMixin):
|
||||
"""Database backup record scoped to a tenant.
|
||||
|
||||
Tracks pg_dump backups with status, file location, and error details.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Compliance models — AI/privacy incident register for EU compliance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class ComplianceIncident(Base, TenantMixin):
|
||||
"""An AI/privacy/security incident tracked for compliance purposes.
|
||||
|
||||
Used by the compliance routes under /api/v1/compliance/incidents.
|
||||
"""
|
||||
|
||||
__tablename__ = "compliance_incidents"
|
||||
__table_args__ = (
|
||||
Index("ix_compliance_incidents_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_compliance_incidents_tenant_type", "tenant_id", "incident_type"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
incident_type: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False, default="ai"
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
affected_use_cases: Mapped[list[Any]] = mapped_column(
|
||||
JSONB, nullable=False, default=list
|
||||
)
|
||||
affected_versions: Mapped[list[Any]] = mapped_column(
|
||||
JSONB, nullable=False, default=list
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||
measures_taken: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
evidence_refs: Mapped[list[Any]] = mapped_column(
|
||||
JSONB, nullable=False, default=list
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="open"
|
||||
)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
resolved_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Computed,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
@@ -39,6 +40,7 @@ class Contact(Base, TenantMixin, OwnedMixin):
|
||||
"""
|
||||
|
||||
__tablename__ = "contacts"
|
||||
indexed_at: Mapped[Any] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_contacts_tenant_code"),
|
||||
UniqueConstraint("tenant_id", "accounting_code", name="uq_contacts_tenant_accounting_code"),
|
||||
@@ -191,7 +193,7 @@ class Contact(Base, TenantMixin, OwnedMixin):
|
||||
)
|
||||
|
||||
|
||||
class ContactPerson(Base, TenantMixin):
|
||||
class ContactPerson(Base, TenantMixin, OwnedMixin):
|
||||
"""Ansprechpartner — 1:N child of a Contact.
|
||||
|
||||
Represents a person working at / associated with a company contact.
|
||||
|
||||
@@ -10,9 +10,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class ContactMergeHistory(Base, TenantMixin):
|
||||
class ContactMergeHistory(Base, TenantMixin, OwnedMixin):
|
||||
"""Records each contact merge operation (source → target).
|
||||
|
||||
When two duplicate contacts are merged, the source contact is soft-deleted
|
||||
|
||||
@@ -9,9 +9,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class Currency(Base, TenantMixin):
|
||||
class Currency(Base, TenantMixin, OwnedMixin):
|
||||
"""Currency entity — e.g. EUR, USD, GBP."""
|
||||
|
||||
__tablename__ = "currencies"
|
||||
|
||||
@@ -35,9 +35,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class EntityPermission(Base, TenantMixin):
|
||||
class EntityPermission(Base, TenantMixin, OwnedMixin):
|
||||
"""Universal ACL entry for any entity in the system.
|
||||
|
||||
entity_type examples: 'contact', 'dms_file', 'mailbox', 'calendar_event',
|
||||
|
||||
@@ -37,9 +37,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class EntityPolicy(Base, TenantMixin):
|
||||
class EntityPolicy(Base, TenantMixin, OwnedMixin):
|
||||
"""ABAC policy entry for any entity type in the system.
|
||||
|
||||
entity_type examples: 'contact', 'dms_file', 'mailbox', 'calendar_event',
|
||||
|
||||
+3
-1
@@ -12,9 +12,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class Group(Base, TenantMixin):
|
||||
class Group(Base, TenantMixin, OwnedMixin):
|
||||
"""Group entity with RBAC permissions and field-level permissions.
|
||||
|
||||
Groups are tenant-scoped. Users can be members of multiple groups.
|
||||
@@ -45,6 +46,7 @@ class UserGroup(Base):
|
||||
"""N:M association — user membership in groups (per tenant)."""
|
||||
|
||||
__tablename__ = "user_groups"
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "group_id", "tenant_id", name="uq_user_groups_user_group_tenant"),
|
||||
)
|
||||
|
||||
@@ -19,9 +19,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class Notification(Base, TenantMixin):
|
||||
class Notification(Base, TenantMixin, OwnedMixin):
|
||||
"""User notification entity."""
|
||||
|
||||
__tablename__ = "notifications"
|
||||
@@ -52,6 +53,7 @@ class NotificationType(Base):
|
||||
"""Registered notification type from a plugin."""
|
||||
|
||||
__tablename__ = "notification_types"
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
__table_args__ = (Index("ix_notification_types_key", "type_key"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
@@ -70,7 +72,7 @@ class NotificationType(Base):
|
||||
)
|
||||
|
||||
|
||||
class NotificationPreference(Base, TenantMixin):
|
||||
class NotificationPreference(Base, TenantMixin, OwnedMixin):
|
||||
"""User preference for a notification type (opt-in/opt-out)."""
|
||||
|
||||
__tablename__ = "notification_preferences"
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Outbox delivery model for per-consumer delivery tracking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
|
||||
|
||||
class OutboxDelivery(Base):
|
||||
"""Tracks per-consumer delivery status for outbox events.
|
||||
|
||||
Each row represents one consumer (event handler) processing one outbox
|
||||
event. An event is only fully 'published' when all mandatory deliveries
|
||||
succeed.
|
||||
"""
|
||||
|
||||
__tablename__ = "outbox_deliveries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("event_id", "consumer_name", name="uq_outbox_deliveries_event_consumer"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
event_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("event_outbox.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
consumer_name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False, server_default="pending",
|
||||
)
|
||||
attempt_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0",
|
||||
)
|
||||
next_attempt_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
@@ -21,9 +21,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class PermissionDelegation(Base, TenantMixin):
|
||||
class PermissionDelegation(Base, TenantMixin, OwnedMixin):
|
||||
"""Permission delegation — temporary handover of permissions.
|
||||
|
||||
from_user_id delegates their permissions to to_user_id
|
||||
|
||||
@@ -20,9 +20,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class PermissionTemplate(Base, TenantMixin):
|
||||
class PermissionTemplate(Base, TenantMixin, OwnedMixin):
|
||||
"""Reusable permission template for entity types.
|
||||
|
||||
When applied to an entity, the template evaluates trigger_condition
|
||||
|
||||
+2
-1
@@ -12,9 +12,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class Role(Base, TenantMixin):
|
||||
class Role(Base, TenantMixin, OwnedMixin):
|
||||
"""Role entity with module→action→permission mapping and field-level permissions."""
|
||||
|
||||
__tablename__ = "roles"
|
||||
|
||||
@@ -17,9 +17,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class Session(Base, TenantMixin):
|
||||
class Session(Base, TenantMixin, OwnedMixin):
|
||||
"""Immutable session audit record. Runtime session lookup uses Redis."""
|
||||
|
||||
__tablename__ = "sessions"
|
||||
|
||||
@@ -4,15 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, Integer, String
|
||||
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class SystemSettings(Base, TenantMixin):
|
||||
class SystemSettings(Base, TenantMixin, OwnedMixin):
|
||||
"""Singleton system settings per tenant — company master data for invoices/quotes."""
|
||||
|
||||
__tablename__ = "system_settings"
|
||||
@@ -50,5 +51,9 @@ class SystemSettings(Base, TenantMixin):
|
||||
theme_accent_color: Mapped[str] = mapped_column(String(20), nullable=False, default="#d946ef")
|
||||
theme_font_family: Mapped[str] = mapped_column(String(100), nullable=False, default="Inter")
|
||||
theme_border_radius: Mapped[str] = mapped_column(String(20), nullable=False, default="0.5rem")
|
||||
# Backup configuration
|
||||
backup_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
# Automation plugin settings (JSONB)
|
||||
automation_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
# Retention policy overrides (JSONB) — compliance module
|
||||
retention_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
+2
-1
@@ -10,9 +10,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class TaxRate(Base, TenantMixin):
|
||||
class TaxRate(Base, TenantMixin, OwnedMixin):
|
||||
"""Tax rate entity — e.g. 'Mehrwertsteuer 19%'."""
|
||||
|
||||
__tablename__ = "tax_rates"
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, SoftDeleteMixin, TimestampMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class User(Base, TimestampMixin, SoftDeleteMixin):
|
||||
|
||||
+12
-2
@@ -37,7 +37,7 @@ class Workflow(Base, TenantMixin, OwnedMixin):
|
||||
)
|
||||
|
||||
|
||||
class WorkflowInstance(Base, TenantMixin):
|
||||
class WorkflowInstance(Base, TenantMixin, OwnedMixin):
|
||||
"""A running instance of a workflow — tracks current step, status, context."""
|
||||
|
||||
__tablename__ = "workflow_instances"
|
||||
@@ -65,9 +65,19 @@ class WorkflowInstance(Base, TenantMixin):
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
timeout_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
timeout_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# G-RUN: Durable/Resume semantics
|
||||
resume_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
resume_reason: Mapped[str | None] = mapped_column(String(50), nullable=True) # wait, approval, event, webhook, cron
|
||||
step_state: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False, server_default="{}")
|
||||
idempotency_key: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
lock_owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
lock_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
max_retries: Mapped[int] = mapped_column(Integer, nullable=False, default=3, server_default="3")
|
||||
|
||||
|
||||
class WorkflowStepHistory(Base, TenantMixin):
|
||||
class WorkflowStepHistory(Base, TenantMixin, OwnedMixin):
|
||||
"""Immutable record of every step transition in a workflow instance."""
|
||||
|
||||
__tablename__ = "workflow_step_history"
|
||||
|
||||
@@ -76,7 +76,7 @@ class Workspace(Base, TenantMixin, OwnedMixin):
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceModule(Base, TenantMixin):
|
||||
class WorkspaceModule(Base, TenantMixin, OwnedMixin):
|
||||
"""Which modules are visible in a workspace and their configuration."""
|
||||
|
||||
__tablename__ = "workspace_modules"
|
||||
@@ -108,7 +108,7 @@ class WorkspaceModule(Base, TenantMixin):
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceUser(Base, TenantMixin):
|
||||
class WorkspaceUser(Base, TenantMixin, OwnedMixin):
|
||||
"""User assignment to a workspace with role (member or manager)."""
|
||||
|
||||
__tablename__ = "workspace_users"
|
||||
@@ -144,7 +144,7 @@ class WorkspaceUser(Base, TenantMixin):
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceWidget(Base, TenantMixin):
|
||||
class WorkspaceWidget(Base, TenantMixin, OwnedMixin):
|
||||
"""Dashboard widget configuration per workspace.
|
||||
|
||||
Multiple instances of the same widget type can exist in the same workspace.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Agent Memory: add metadata JSONB column for structured memory metadata
|
||||
|
||||
ALTER TABLE agent_memories ADD COLUMN IF NOT EXISTS metadata JSONB;
|
||||
@@ -3,13 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Index, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
from pgvector.sqlalchemy import Vector
|
||||
|
||||
|
||||
class AgentMemory(Base, TenantMixin, OwnedMixin):
|
||||
@@ -35,5 +38,8 @@ class AgentMemory(Base, TenantMixin, OwnedMixin):
|
||||
String(50), nullable=False, default="fact"
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
"metadata", JSONB, nullable=True
|
||||
)
|
||||
# embedding column is managed via raw SQL (pgvector extension)
|
||||
# embedding vector(768) — see migration 0001_initial.sql
|
||||
|
||||
@@ -80,26 +80,29 @@ async def run_agent_external(
|
||||
|
||||
# Create or find a session for this external interaction
|
||||
|
||||
from app.plugins.builtins.ai_assistant.models import AIChatMessage, AIChatSession
|
||||
# AIChatSession/AIChatMessage removed — using comm tables
|
||||
|
||||
session = AIChatSession(
|
||||
user_id=uuid.UUID(current_user["user_id"]),
|
||||
agent_id=agent.id,
|
||||
title=f"External: {data.message[:50]}" if data.message else "External Agent Run",
|
||||
is_sidebar=False,
|
||||
# Create a comm conversation for this external interaction
|
||||
from app.plugins.builtins.kommunikation.models import CommConversation
|
||||
session = CommConversation(
|
||||
tenant_id=tenant_id,
|
||||
title=f"External: {data.message[:50]}" if data.message else "External Agent Run",
|
||||
owner_id=uuid.UUID(current_user["user_id"]),
|
||||
created_by=uuid.UUID(current_user["user_id"]),
|
||||
created_by_type="user",
|
||||
metadata_={"conversation_type": "ai", "agent_id": str(agent.id)},
|
||||
)
|
||||
db.add(session)
|
||||
await db.flush()
|
||||
|
||||
# Store the user message
|
||||
user_msg = AIChatMessage(
|
||||
session_id=session.id,
|
||||
role="user",
|
||||
from app.plugins.builtins.kommunikation.models import CommMessage
|
||||
user_msg = CommMessage(
|
||||
conversation_id=session.id,
|
||||
sender_id=uuid.UUID(current_user["user_id"]),
|
||||
sender_type="user",
|
||||
content=data.message,
|
||||
tokens=0,
|
||||
model_used=agent.name or "external",
|
||||
content_format="text",
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
db.add(user_msg)
|
||||
@@ -117,7 +120,7 @@ async def run_agent_external(
|
||||
}
|
||||
|
||||
# Run the agent via streaming chat (non-streaming mode)
|
||||
from app.plugins.builtins.ai_assistant.services import stream_chat
|
||||
from app.plugins.builtins.ai_assistant.services import stream_chat_comm
|
||||
|
||||
full_response = ""
|
||||
async with get_db() as stream_db:
|
||||
@@ -250,15 +253,16 @@ async def stream_agent_external(
|
||||
raise HTTPException(status_code=400, detail="Agent is not active")
|
||||
|
||||
# Create session
|
||||
from app.plugins.builtins.ai_assistant.models import AIChatSession
|
||||
# AIChatSession removed — using comm tables
|
||||
|
||||
session = AIChatSession(
|
||||
user_id=uuid.UUID(current_user["user_id"]),
|
||||
agent_id=agent.id,
|
||||
title=f"External Stream: {data.message[:50]}" if data.message else "External Agent Stream",
|
||||
is_sidebar=False,
|
||||
from app.plugins.builtins.kommunikation.models import CommConversation
|
||||
session = CommConversation(
|
||||
tenant_id=tenant_id,
|
||||
title=f"External Stream: {data.message[:50]}" if data.message else "External Agent Stream",
|
||||
owner_id=uuid.UUID(current_user["user_id"]),
|
||||
created_by=uuid.UUID(current_user["user_id"]),
|
||||
created_by_type="user",
|
||||
metadata_={"conversation_type": "ai", "agent_id": str(agent.id)},
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
@@ -274,15 +278,15 @@ async def stream_agent_external(
|
||||
"field_permissions": current_user.get("field_permissions", {}),
|
||||
}
|
||||
|
||||
from app.plugins.builtins.ai_assistant.services import stream_chat
|
||||
from app.plugins.builtins.ai_assistant.services import stream_chat_comm
|
||||
|
||||
async def event_stream():
|
||||
from app.core.db import get_session_factory
|
||||
factory = get_session_factory()
|
||||
async with factory() as stream_db:
|
||||
await set_tenant_context(stream_db, tenant_id)
|
||||
async for chunk in stream_chat(
|
||||
stream_db, session, agent, data.message, user_context, tenant_id
|
||||
async for chunk in stream_chat_comm(
|
||||
stream_db, session.id, agent, data.message, user_context, tenant_id, uuid.UUID(current_user["user_id"])
|
||||
):
|
||||
yield chunk
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
# --- Providers ---
|
||||
|
||||
class AIProvider(Base, TenantMixin):
|
||||
class AIProvider(Base, TenantMixin, OwnedMixin):
|
||||
"""LLM provider configuration (OpenAI, Anthropic, Ollama, etc.)."""
|
||||
|
||||
__tablename__ = "ai_providers"
|
||||
@@ -53,7 +53,7 @@ class AIProvider(Base, TenantMixin):
|
||||
|
||||
# --- Models ---
|
||||
|
||||
class AIModel(Base, TenantMixin):
|
||||
class AIModel(Base, TenantMixin, OwnedMixin):
|
||||
"""Available model per provider."""
|
||||
|
||||
__tablename__ = "ai_models"
|
||||
@@ -81,7 +81,7 @@ class AIModel(Base, TenantMixin):
|
||||
|
||||
# --- Presets ---
|
||||
|
||||
class AIPreset(Base, TenantMixin):
|
||||
class AIPreset(Base, TenantMixin, OwnedMixin):
|
||||
"""Model preset: model + parameters + optional system prompt."""
|
||||
|
||||
__tablename__ = "ai_presets"
|
||||
@@ -134,67 +134,9 @@ class AIAgent(Base, TenantMixin, OwnedMixin):
|
||||
config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
|
||||
|
||||
# --- Chat Sessions ---
|
||||
|
||||
class AIChatSession(Base, TenantMixin, OwnedMixin):
|
||||
"""Chat session for a user with a specific agent."""
|
||||
|
||||
__tablename__ = "ai_chat_sessions"
|
||||
__table_args__ = (
|
||||
Index("ix_ai_sessions_user", "user_id"),
|
||||
Index("ix_ai_sessions_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
agent_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("ai_agents.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False, default="Neuer Chat")
|
||||
is_pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_sidebar: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
folder_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("ai_chat_folders.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
# --- Chat Messages ---
|
||||
|
||||
class AIChatMessage(Base, TenantMixin):
|
||||
"""Individual message in a chat session."""
|
||||
|
||||
__tablename__ = "ai_chat_messages"
|
||||
__table_args__ = (
|
||||
Index("ix_ai_messages_session", "session_id"),
|
||||
Index("ix_ai_messages_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("ai_chat_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
tool_calls: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
tool_results: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
model_used: Mapped[str] = mapped_column(String(200), nullable=False, default="")
|
||||
|
||||
|
||||
# --- Chat Folders ---
|
||||
|
||||
class AIChatFolder(Base, TenantMixin):
|
||||
class AIChatFolder(Base, TenantMixin, OwnedMixin):
|
||||
"""Folder for organizing chat sessions."""
|
||||
|
||||
__tablename__ = "ai_chat_folders"
|
||||
@@ -215,34 +157,3 @@ class AIChatFolder(Base, TenantMixin):
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
# --- Chat Attachments ---
|
||||
|
||||
class AIChatAttachment(Base, TenantMixin):
|
||||
"""File attached to a chat message."""
|
||||
|
||||
__tablename__ = "ai_chat_attachments"
|
||||
__table_args__ = (
|
||||
Index("ix_ai_attachments_message", "message_id"),
|
||||
Index("ix_ai_attachments_session", "session_id"),
|
||||
Index("ix_ai_attachments_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
message_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("ai_chat_messages.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("ai_chat_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
mime_type: Mapped[str] = mapped_column(String(255), nullable=False, default="application/octet-stream")
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
storage_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
|
||||
@@ -78,8 +78,8 @@ class AIAssistantPlugin(BasePlugin):
|
||||
await seed_defaults(db)
|
||||
|
||||
def get_entity_models(self) -> dict[str, type]:
|
||||
from app.plugins.builtins.ai_assistant.models import AIAgent, AIChatSession
|
||||
return {"ai_agent": AIAgent, "ai_chat_session": AIChatSession}
|
||||
from app.plugins.builtins.ai_assistant.models import AIAgent
|
||||
return {"ai_agent": AIAgent}
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
"""Activate plugin: register CRM API tool and participant handler."""
|
||||
|
||||
@@ -6,9 +6,8 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import aiofiles
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -17,9 +16,7 @@ from app.core.visibility import apply_visibility_filter
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.ai_assistant.models import (
|
||||
AIAgent,
|
||||
AIChatAttachment,
|
||||
AIChatFolder,
|
||||
AIChatSession,
|
||||
AIModel,
|
||||
AIPreset,
|
||||
AIProvider,
|
||||
@@ -36,25 +33,19 @@ from app.plugins.builtins.ai_assistant.schemas import (
|
||||
ChatFolderCreate,
|
||||
ChatFolderUpdate,
|
||||
ChatSendRequest,
|
||||
ChatSessionCreate,
|
||||
ChatSessionUpdate,
|
||||
)
|
||||
from app.plugins.builtins.ai_assistant.services import (
|
||||
agent_to_response,
|
||||
attachment_to_response,
|
||||
folder_to_response,
|
||||
get_agent_by_id,
|
||||
get_comm_messages,
|
||||
get_default_agent,
|
||||
get_preset_by_id,
|
||||
get_provider_by_id,
|
||||
get_session_by_id,
|
||||
get_session_messages,
|
||||
message_to_response,
|
||||
model_to_response,
|
||||
preset_to_response,
|
||||
provider_to_response,
|
||||
session_to_response,
|
||||
stream_chat,
|
||||
stream_chat_comm,
|
||||
)
|
||||
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
||||
|
||||
@@ -106,8 +97,9 @@ async def create_provider(
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
db.add(provider)
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(provider)
|
||||
await db.commit()
|
||||
return provider_to_response(provider)
|
||||
|
||||
|
||||
@@ -135,8 +127,9 @@ async def update_provider(
|
||||
|
||||
for field, val in data.model_dump(exclude_unset=True).items():
|
||||
setattr(provider, field, val)
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(provider)
|
||||
await db.commit()
|
||||
return provider_to_response(provider)
|
||||
|
||||
|
||||
@@ -193,8 +186,9 @@ async def create_model(
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
db.add(model)
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(model)
|
||||
await db.commit()
|
||||
return model_to_response(model)
|
||||
|
||||
|
||||
@@ -217,8 +211,9 @@ async def update_model(
|
||||
|
||||
for field, val in data.model_dump(exclude_unset=True).items():
|
||||
setattr(model, field, val)
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(model)
|
||||
await db.commit()
|
||||
return model_to_response(model)
|
||||
|
||||
|
||||
@@ -279,8 +274,9 @@ async def create_preset(
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
db.add(preset)
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(preset)
|
||||
await db.commit()
|
||||
return preset_to_response(preset)
|
||||
|
||||
|
||||
@@ -301,8 +297,9 @@ async def update_preset(
|
||||
update_data["provider_id"] = uuid.UUID(update_data["provider_id"])
|
||||
for field, val in update_data.items():
|
||||
setattr(preset, field, val)
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(preset)
|
||||
await db.commit()
|
||||
return preset_to_response(preset)
|
||||
|
||||
|
||||
@@ -362,8 +359,9 @@ async def create_agent(
|
||||
owner_id=user_id,
|
||||
)
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(agent)
|
||||
await db.commit()
|
||||
return agent_to_response(agent)
|
||||
|
||||
|
||||
@@ -384,8 +382,9 @@ async def update_agent(
|
||||
update_data["preset_id"] = uuid.UUID(update_data["preset_id"])
|
||||
for field, val in update_data.items():
|
||||
setattr(agent, field, val)
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(agent)
|
||||
await db.commit()
|
||||
return agent_to_response(agent)
|
||||
|
||||
|
||||
@@ -417,207 +416,6 @@ async def list_tools(
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
# ─── Chat Sessions ───
|
||||
|
||||
@router.get("/sessions", dependencies=[Depends(require_permission("ai:read"))])
|
||||
async def list_sessions(
|
||||
is_sidebar: bool | None = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("is_system_admin", False)
|
||||
stmt = (
|
||||
select(AIChatSession)
|
||||
.where(AIChatSession.tenant_id == tenant_id)
|
||||
)
|
||||
stmt = await apply_visibility_filter(
|
||||
db, stmt, "ai_chat_session", AIChatSession, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
if is_sidebar is not None:
|
||||
stmt = stmt.where(AIChatSession.is_sidebar == is_sidebar)
|
||||
stmt = stmt.order_by(AIChatSession.updated_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
sessions = list(result.scalars().all())
|
||||
return [session_to_response(s) for s in sessions]
|
||||
|
||||
|
||||
@router.post("/sessions", dependencies=[Depends(require_permission("ai:write"))])
|
||||
async def create_session(
|
||||
data: ChatSessionCreate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
await set_tenant_context(db, tenant_id)
|
||||
|
||||
agent_id = None
|
||||
if data.agent_id:
|
||||
agent_id = uuid.UUID(data.agent_id)
|
||||
elif not data.is_sidebar:
|
||||
# Use default agent for non-sidebar sessions
|
||||
default_agent = await get_default_agent(db, tenant_id)
|
||||
if default_agent:
|
||||
agent_id = default_agent.id
|
||||
else:
|
||||
# Sidebar also gets default agent
|
||||
default_agent = await get_default_agent(db, tenant_id)
|
||||
if default_agent:
|
||||
agent_id = default_agent.id
|
||||
|
||||
folder_id = None
|
||||
if data.folder_id:
|
||||
folder_id = uuid.UUID(data.folder_id)
|
||||
|
||||
session = AIChatSession(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
title=data.title,
|
||||
is_sidebar=data.is_sidebar,
|
||||
folder_id=folder_id,
|
||||
tenant_id=tenant_id,
|
||||
owner_id=user_id,
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session_to_response(session)
|
||||
|
||||
|
||||
@router.put("/sessions/{session_id}", dependencies=[Depends(require_permission("ai:write"))])
|
||||
async def update_session(
|
||||
session_id: str,
|
||||
data: ChatSessionUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if "agent_id" in update_data and update_data["agent_id"]:
|
||||
update_data["agent_id"] = uuid.UUID(update_data["agent_id"])
|
||||
if "folder_id" in update_data:
|
||||
if update_data["folder_id"]:
|
||||
update_data["folder_id"] = uuid.UUID(update_data["folder_id"])
|
||||
else:
|
||||
update_data["folder_id"] = None
|
||||
for field, val in update_data.items():
|
||||
setattr(session, field, val)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session_to_response(session)
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}", dependencies=[Depends(require_permission("ai:write"))])
|
||||
async def delete_session(
|
||||
session_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
await db.delete(session)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ─── Chat Messages ───
|
||||
|
||||
@router.get("/sessions/{session_id}/messages", dependencies=[Depends(require_permission("ai:read"))])
|
||||
async def list_messages(
|
||||
session_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
messages = await get_session_messages(db, session.id, tenant_id)
|
||||
return [message_to_response(m) for m in messages]
|
||||
|
||||
|
||||
# ─── Streaming Chat ───
|
||||
|
||||
@router.post("/sessions/{session_id}/stream", dependencies=[Depends(require_permission("ai:write"))])
|
||||
async def chat_stream(
|
||||
session_id: str,
|
||||
data: ChatSendRequest,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
# Rate limit — AI policy (cost-sensitive LLM call)
|
||||
from app.core.rate_limit import RateLimitPolicy, check_rate_limit_policy
|
||||
await check_rate_limit_policy(
|
||||
f"rate:ai:chat:{tenant_id}:{user_id}",
|
||||
RateLimitPolicy.AI,
|
||||
)
|
||||
|
||||
await set_tenant_context(db, tenant_id)
|
||||
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
# Get agent
|
||||
agent = None
|
||||
if data.agent_id:
|
||||
agent = await get_agent_by_id(db, uuid.UUID(data.agent_id), tenant_id)
|
||||
elif session.agent_id:
|
||||
agent = await get_agent_by_id(db, session.agent_id, tenant_id)
|
||||
if not agent:
|
||||
agent = await get_default_agent(db, tenant_id)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=400, detail="No agent available")
|
||||
|
||||
# Build user context for RBAC checks in tools
|
||||
user_context = {
|
||||
"user_id": current_user["user_id"],
|
||||
"tenant_id": current_user["tenant_id"],
|
||||
"role": current_user.get("role", ""),
|
||||
"permissions": current_user.get("permissions", []),
|
||||
"denied_permissions": current_user.get("denied_permissions", []),
|
||||
"is_system_admin": current_user.get("is_system_admin", False),
|
||||
"field_permissions": current_user.get("field_permissions", {}),
|
||||
}
|
||||
|
||||
async def event_stream():
|
||||
# Use a fresh DB session — the Depends(get_db) session closes after response
|
||||
from app.core.db import get_session_factory
|
||||
factory = get_session_factory()
|
||||
async with factory() as stream_db:
|
||||
await set_tenant_context(stream_db, tenant_id)
|
||||
async for chunk in stream_chat(
|
||||
stream_db, session, agent, data.content, user_context, tenant_id
|
||||
):
|
||||
yield chunk
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ─── Chat Folders ───
|
||||
|
||||
@router.get("/folders", dependencies=[Depends(require_permission("ai:read"))])
|
||||
@@ -654,8 +452,9 @@ async def create_folder(
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
db.add(folder)
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(folder)
|
||||
await db.commit()
|
||||
return folder_to_response(folder)
|
||||
|
||||
|
||||
@@ -686,8 +485,9 @@ async def update_folder(
|
||||
update_data["parent_id"] = None
|
||||
for field, val in update_data.items():
|
||||
setattr(folder, field, val)
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(folder)
|
||||
await db.commit()
|
||||
return folder_to_response(folder)
|
||||
|
||||
|
||||
@@ -713,93 +513,78 @@ async def delete_folder(
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ─── Attachments ───
|
||||
# ─── AI Chat Streaming (via comm_conversations) ───
|
||||
|
||||
import os # noqa: E402
|
||||
from pathlib import Path # noqa: E402
|
||||
|
||||
ATTACHMENT_DIR = Path(os.environ.get("STORAGE_PATH", "/data/storage")) / "ai_attachments"
|
||||
MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024 # 25MB
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/attachments", response_model=None, dependencies=[Depends(require_permission("ai:write"))])
|
||||
async def upload_attachment(
|
||||
session_id: str,
|
||||
file: UploadFile,
|
||||
@router.post("/conversations/{conversation_id}/stream", dependencies=[Depends(require_permission("ai:write"))])
|
||||
async def chat_stream_comm(
|
||||
conversation_id: str,
|
||||
data: ChatSendRequest,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Stream AI chat response for a comm conversation with conversation_type='ai'."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > MAX_ATTACHMENT_SIZE:
|
||||
raise HTTPException(status_code=413, detail="File too large (max 25MB)")
|
||||
|
||||
ATTACHMENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
file_id = str(uuid.uuid4())
|
||||
safe_filename = file.filename or "unnamed"
|
||||
storage_path = str(ATTACHMENT_DIR / f"{file_id}_{safe_filename}")
|
||||
async with aiofiles.open(storage_path, "wb") as f:
|
||||
await f.write(content)
|
||||
|
||||
attachment = AIChatAttachment(
|
||||
session_id=session.id,
|
||||
filename=safe_filename,
|
||||
mime_type=file.content_type or "application/octet-stream",
|
||||
size_bytes=len(content),
|
||||
storage_path=storage_path,
|
||||
tenant_id=tenant_id,
|
||||
from app.core.rate_limit import RateLimitPolicy, check_rate_limit_policy
|
||||
await check_rate_limit_policy(
|
||||
f"rate:ai:chat:{tenant_id}:{user_id}",
|
||||
RateLimitPolicy.AI,
|
||||
)
|
||||
|
||||
await set_tenant_context(db, tenant_id)
|
||||
|
||||
agent = None
|
||||
if data.agent_id:
|
||||
agent = await get_agent_by_id(db, uuid.UUID(data.agent_id), tenant_id)
|
||||
if not agent:
|
||||
agent = await get_default_agent(db, tenant_id)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=400, detail="No agent available")
|
||||
|
||||
user_context = {
|
||||
"user_id": current_user["user_id"],
|
||||
"tenant_id": current_user["tenant_id"],
|
||||
"role": current_user.get("role", ""),
|
||||
"permissions": current_user.get("permissions", []),
|
||||
"denied_permissions": current_user.get("denied_permissions", []),
|
||||
"is_system_admin": current_user.get("is_system_admin", False),
|
||||
"field_permissions": current_user.get("field_permissions", {}),
|
||||
}
|
||||
|
||||
async def event_stream():
|
||||
from app.core.db import get_session_factory
|
||||
factory = get_session_factory()
|
||||
async with factory() as stream_db:
|
||||
await set_tenant_context(stream_db, tenant_id)
|
||||
async for chunk in stream_chat_comm(
|
||||
stream_db, uuid.UUID(conversation_id), agent, data.content, user_context, tenant_id, user_id
|
||||
):
|
||||
yield chunk
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
db.add(attachment)
|
||||
await db.commit()
|
||||
await db.refresh(attachment)
|
||||
return attachment_to_response(attachment)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/attachments", dependencies=[Depends(require_permission("ai:read"))])
|
||||
async def list_attachments(
|
||||
session_id: str,
|
||||
@router.get("/conversations/{conversation_id}/messages", dependencies=[Depends(require_permission("ai:read"))])
|
||||
async def list_comm_messages(
|
||||
conversation_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List messages for an AI comm conversation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(AIChatAttachment)
|
||||
.where(AIChatAttachment.session_id == session.id)
|
||||
.where(AIChatAttachment.tenant_id == tenant_id)
|
||||
.order_by(AIChatAttachment.created_at.asc())
|
||||
)
|
||||
attachments = list(result.scalars().all())
|
||||
return [attachment_to_response(a) for a in attachments]
|
||||
await set_tenant_context(db, tenant_id)
|
||||
messages = await get_comm_messages(db, uuid.UUID(conversation_id), tenant_id)
|
||||
return [{"role": m["role"], "content": m["content"]} for m in messages]
|
||||
|
||||
|
||||
@router.get("/attachments/{attachment_id}/download", dependencies=[Depends(require_permission("ai:read"))])
|
||||
async def download_attachment(
|
||||
attachment_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
result = await db.execute(
|
||||
select(AIChatAttachment)
|
||||
.where(AIChatAttachment.id == uuid.UUID(attachment_id))
|
||||
.where(AIChatAttachment.tenant_id == tenant_id)
|
||||
)
|
||||
attachment = result.scalar_one_or_none()
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
return FileResponse(
|
||||
attachment.storage_path,
|
||||
filename=attachment.filename,
|
||||
media_type=attachment.mime_type,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import aiofiles
|
||||
import litellm
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -22,10 +21,7 @@ from app.ai.llm_client import llm_complete
|
||||
from app.core.permissions import check_permission
|
||||
from app.plugins.builtins.ai_assistant.models import (
|
||||
AIAgent,
|
||||
AIChatAttachment,
|
||||
AIChatFolder,
|
||||
AIChatMessage,
|
||||
AIChatSession,
|
||||
AIModel,
|
||||
AIPreset,
|
||||
AIProvider,
|
||||
@@ -110,35 +106,6 @@ def agent_to_response(agent: AIAgent) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def session_to_response(session: AIChatSession) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(session.id),
|
||||
"user_id": str(session.user_id),
|
||||
"agent_id": str(session.agent_id) if session.agent_id else None,
|
||||
"title": session.title,
|
||||
"is_pinned": session.is_pinned,
|
||||
"is_sidebar": session.is_sidebar,
|
||||
"folder_id": str(session.folder_id) if session.folder_id else None,
|
||||
"sort_order": session.sort_order,
|
||||
"created_at": session.created_at.isoformat() if session.created_at else None,
|
||||
"updated_at": session.updated_at.isoformat() if session.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def message_to_response(msg: AIChatMessage) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(msg.id),
|
||||
"session_id": str(msg.session_id),
|
||||
"role": msg.role,
|
||||
"content": msg.content,
|
||||
"tool_calls": msg.tool_calls if msg.tool_calls else None,
|
||||
"tool_results": msg.tool_results if msg.tool_results else None,
|
||||
"tokens": msg.tokens,
|
||||
"model_used": msg.model_used,
|
||||
"created_at": msg.created_at.isoformat() if msg.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def folder_to_response(folder: AIChatFolder) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
@@ -150,17 +117,6 @@ def folder_to_response(folder: AIChatFolder) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def attachment_to_response(att: AIChatAttachment) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(att.id),
|
||||
"message_id": str(att.message_id) if att.message_id else None,
|
||||
"session_id": str(att.session_id),
|
||||
"filename": att.filename,
|
||||
"mime_type": att.mime_type,
|
||||
"size_bytes": att.size_bytes,
|
||||
}
|
||||
|
||||
|
||||
# ─── Provider/Model/Preset/Agent CRUD ───
|
||||
|
||||
async def get_default_provider(db: AsyncSession, tenant_id: uuid.UUID) -> AIProvider | None:
|
||||
@@ -214,60 +170,159 @@ async def get_default_agent(db: AsyncSession, tenant_id: uuid.UUID) -> AIAgent |
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# ─── Session/Message helpers ───
|
||||
# ─── Comm-based Chat Helpers (replaces AIChatSession/AIChatMessage) ───
|
||||
|
||||
async def get_session_by_id(
|
||||
db: AsyncSession, session_id: uuid.UUID, user_id: uuid.UUID, tenant_id: uuid.UUID
|
||||
) -> AIChatSession | None:
|
||||
async def get_comm_messages(
|
||||
db: AsyncSession, conversation_id: uuid.UUID, tenant_id: uuid.UUID
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get message history from comm_messages for an AI conversation."""
|
||||
from app.plugins.builtins.kommunikation.models import CommMessage
|
||||
result = await db.execute(
|
||||
select(AIChatSession)
|
||||
.where(AIChatSession.id == session_id)
|
||||
.where(AIChatSession.user_id == user_id)
|
||||
.where(AIChatSession.tenant_id == tenant_id)
|
||||
.limit(1)
|
||||
select(CommMessage)
|
||||
.where(CommMessage.conversation_id == conversation_id)
|
||||
.where(CommMessage.tenant_id == tenant_id)
|
||||
.order_by(CommMessage.created_at.asc())
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
msgs = list(result.scalars().all())
|
||||
return [{"role": m.sender_type if m.sender_type != "ai" else "assistant", "content": m.content} for m in msgs]
|
||||
|
||||
|
||||
async def get_session_messages(
|
||||
db: AsyncSession, session_id: uuid.UUID, tenant_id: uuid.UUID
|
||||
) -> list[AIChatMessage]:
|
||||
result = await db.execute(
|
||||
select(AIChatMessage)
|
||||
.where(AIChatMessage.session_id == session_id)
|
||||
.where(AIChatMessage.tenant_id == tenant_id)
|
||||
.order_by(AIChatMessage.created_at.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def save_message(
|
||||
async def save_comm_message(
|
||||
db: AsyncSession,
|
||||
session_id: uuid.UUID,
|
||||
conversation_id: uuid.UUID,
|
||||
role: str,
|
||||
content: str,
|
||||
tenant_id: uuid.UUID,
|
||||
tool_calls: list | None = None,
|
||||
tool_results: list | None = None,
|
||||
tokens: int = 0,
|
||||
model_used: str = "",
|
||||
) -> AIChatMessage:
|
||||
msg = AIChatMessage(
|
||||
session_id=session_id,
|
||||
role=role,
|
||||
user_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Save a message to comm_messages for an AI conversation."""
|
||||
from app.plugins.builtins.kommunikation.models import CommMessage
|
||||
sender_type = "user" if role == "user" else "ai"
|
||||
msg = CommMessage(
|
||||
conversation_id=conversation_id,
|
||||
sender_id=user_id if role == "user" else None,
|
||||
sender_type=sender_type,
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
tool_results=tool_results,
|
||||
tokens=tokens,
|
||||
model_used=model_used,
|
||||
content_format="text",
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
db.add(msg)
|
||||
await db.flush()
|
||||
return msg
|
||||
|
||||
|
||||
# ─── LLM Chat with Tool Loop ───
|
||||
async def stream_chat_comm(
|
||||
db: AsyncSession,
|
||||
conversation_id: uuid.UUID,
|
||||
agent: AIAgent,
|
||||
user_message: str,
|
||||
user_context: dict[str, Any],
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Stream chat response via SSE with tool-calling loop, using comm_messages."""
|
||||
history = await get_comm_messages(db, conversation_id, tenant_id)
|
||||
messages: list[dict[str, Any]] = list(history)
|
||||
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
await save_comm_message(db, conversation_id, "user", user_message, tenant_id, user_id)
|
||||
|
||||
# Get agent tools — always include call_crm_api for full system access
|
||||
registry = get_tool_registry()
|
||||
tools = registry.get_by_names(agent.tool_ids or [])
|
||||
crm_api_tool = registry.get("call_crm_api")
|
||||
if crm_api_tool and crm_api_tool not in tools:
|
||||
tools.append(crm_api_tool)
|
||||
tool_schemas = [t.to_openai_schema() for t in tools] if tools else None
|
||||
|
||||
# Build LLM params
|
||||
params, model_id = await build_litellm_params(db, agent, messages, tenant_id)
|
||||
|
||||
# Agent loop: LLM → tool calls → execute → feed back → repeat
|
||||
max_iterations = 5
|
||||
for iteration in range(max_iterations):
|
||||
if tool_schemas and iteration < max_iterations - 1:
|
||||
params["tools"] = tool_schemas
|
||||
elif "tools" in params:
|
||||
del params["tools"]
|
||||
|
||||
collected_content = ""
|
||||
collected_tool_calls: list[dict[str, Any]] = []
|
||||
try:
|
||||
result = await llm_complete(
|
||||
model=params.get("model", "gpt-4o-mini"),
|
||||
messages=params.get("messages", []),
|
||||
temperature=params.get("temperature", 0.7),
|
||||
max_tokens=params.get("max_tokens", 2048),
|
||||
api_key=params.get("api_key"),
|
||||
api_base=params.get("api_base"),
|
||||
tools=params.get("tools"),
|
||||
)
|
||||
collected_content = result["content"]
|
||||
if collected_content:
|
||||
yield f"data: {json.dumps({'type': 'token', 'content': collected_content})}\n\n"
|
||||
raw_response = result["raw_response"]
|
||||
if hasattr(raw_response.choices[0].message, "tool_calls") and raw_response.choices[0].message.tool_calls:
|
||||
for tc in raw_response.choices[0].message.tool_calls:
|
||||
collected_tool_calls.append({
|
||||
"id": tc.id or "",
|
||||
"function": {
|
||||
"name": tc.function.name if tc.function else "",
|
||||
"arguments": tc.function.arguments if tc.function and tc.function.arguments else "",
|
||||
},
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error("LLM error: %s", exc)
|
||||
yield f"data: {json.dumps({'type': 'error', 'content': str(exc)})}\n\n"
|
||||
await save_comm_message(db, conversation_id, "assistant", f"Error: {exc}", tenant_id, user_id)
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
if collected_tool_calls:
|
||||
await save_comm_message(
|
||||
db, conversation_id, "assistant", collected_content, tenant_id, user_id,
|
||||
)
|
||||
yield f"data: {json.dumps({'type': 'tool_calls', 'tools': [tc['function']['name'] for tc in collected_tool_calls]})}\n\n"
|
||||
|
||||
for tc in collected_tool_calls:
|
||||
tool_name = tc["function"]["name"]
|
||||
try:
|
||||
tool_args = json.loads(tc["function"]["arguments"])
|
||||
except json.JSONDecodeError:
|
||||
tool_args = {}
|
||||
|
||||
tool = registry.get(tool_name)
|
||||
if tool is None:
|
||||
result = f"Tool '{tool_name}' not found"
|
||||
else:
|
||||
result = await execute_tool_call(tool, tool_args, user_context)
|
||||
|
||||
yield f"data: {json.dumps({'type': 'tool_result', 'tool': tool_name, 'result': result[:500]})}\n\n"
|
||||
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": collected_content,
|
||||
"tool_calls": collected_tool_calls,
|
||||
})
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc["id"],
|
||||
"name": tool_name,
|
||||
"content": result,
|
||||
})
|
||||
|
||||
params, model_id = await build_litellm_params(db, agent, messages, tenant_id)
|
||||
continue
|
||||
|
||||
# No tool calls — final response
|
||||
await save_comm_message(db, conversation_id, "assistant", collected_content, tenant_id, user_id)
|
||||
await db.commit()
|
||||
yield f"data: {json.dumps({'type': 'done', 'content': collected_content})}\n\n"
|
||||
return
|
||||
|
||||
# Max iterations reached
|
||||
await save_comm_message(db, conversation_id, "assistant", collected_content, tenant_id, user_id)
|
||||
await db.commit()
|
||||
yield f"data: {json.dumps({'type': 'done', 'content': collected_content})}\n\n"
|
||||
|
||||
async def build_litellm_params(
|
||||
db: AsyncSession,
|
||||
@@ -364,190 +419,6 @@ async def execute_tool_call(
|
||||
return f"Error executing tool '{tool.name}': {exc}"
|
||||
|
||||
|
||||
async def _extract_attachment_content(
|
||||
db: AsyncSession,
|
||||
session_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> str:
|
||||
"""Extract text content from session attachments for LLM context."""
|
||||
result = await db.execute(
|
||||
select(AIChatAttachment)
|
||||
.where(AIChatAttachment.session_id == session_id)
|
||||
.where(AIChatAttachment.tenant_id == tenant_id)
|
||||
.order_by(AIChatAttachment.created_at.asc())
|
||||
)
|
||||
attachments = list(result.scalars().all())
|
||||
if not attachments:
|
||||
return ""
|
||||
|
||||
parts: list[str] = []
|
||||
for att in attachments:
|
||||
try:
|
||||
async with aiofiles.open(att.storage_path, "rb") as f:
|
||||
content = await f.read()
|
||||
|
||||
text_content = ""
|
||||
mime = att.mime_type.lower()
|
||||
|
||||
if mime.startswith("text/") or att.filename.endswith((".txt", ".md", ".csv", ".json", ".yaml", ".yml", ".py", ".js", ".ts", ".html", ".xml")):
|
||||
text_content = content.decode("utf-8", errors="replace")
|
||||
elif mime == "application/pdf" or att.filename.endswith(".pdf"):
|
||||
try:
|
||||
from io import BytesIO
|
||||
|
||||
from pypdf import PdfReader
|
||||
reader = PdfReader(BytesIO(content))
|
||||
text_content = "\n".join(page.extract_text() or "" for page in reader.pages)
|
||||
except ImportError:
|
||||
text_content = f"[PDF file: {att.filename} - extraction not available]"
|
||||
elif mime.startswith("image/"):
|
||||
text_content = f"[Image file: {att.filename} ({att.mime_type}, {att.size_bytes} bytes)]"
|
||||
else:
|
||||
text_content = f"[Binary file: {att.filename} ({att.mime_type}, {att.size_bytes} bytes)]"
|
||||
|
||||
if len(text_content) > 10000:
|
||||
text_content = text_content[:10000] + "\n... [truncated]"
|
||||
|
||||
parts.append(f"--- Attachment: {att.filename} ---\n{text_content}")
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to extract attachment %s: %s", att.filename, exc)
|
||||
parts.append(f"--- Attachment: {att.filename} (extraction failed) ---")
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
async def stream_chat(
|
||||
db: AsyncSession,
|
||||
session: AIChatSession,
|
||||
agent: AIAgent,
|
||||
user_message: str,
|
||||
user_context: dict[str, Any],
|
||||
tenant_id: uuid.UUID,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Stream chat response via SSE with tool-calling loop."""
|
||||
history = await get_session_messages(db, session.id, tenant_id)
|
||||
messages: list[dict[str, Any]] = []
|
||||
for msg in history:
|
||||
messages.append({"role": msg.role, "content": msg.content})
|
||||
|
||||
attachment_content = await _extract_attachment_content(db, session.id, tenant_id)
|
||||
full_message = user_message
|
||||
if attachment_content:
|
||||
full_message = f"{user_message}\n\n--- Attached Files ---\n{attachment_content}"
|
||||
|
||||
messages.append({"role": "user", "content": full_message})
|
||||
await save_message(db, session.id, "user", user_message, tenant_id)
|
||||
|
||||
# Get agent tools — always include call_crm_api for full system access
|
||||
registry = get_tool_registry()
|
||||
tools = registry.get_by_names(agent.tool_ids or [])
|
||||
# Ensure call_crm_api is always available
|
||||
crm_api_tool = registry.get("call_crm_api")
|
||||
if crm_api_tool and crm_api_tool not in tools:
|
||||
tools.append(crm_api_tool)
|
||||
tool_schemas = [t.to_openai_schema() for t in tools] if tools else None
|
||||
|
||||
# Build LLM params
|
||||
params, model_id = await build_litellm_params(db, agent, messages, tenant_id)
|
||||
|
||||
# Agent loop: LLM → tool calls → execute → feed back → repeat
|
||||
max_iterations = 5
|
||||
for iteration in range(max_iterations):
|
||||
# Add tools to params if available, but NOT on the last iteration
|
||||
# to force the LLM to give a final answer instead of looping
|
||||
if tool_schemas and iteration < max_iterations - 1:
|
||||
params["tools"] = tool_schemas
|
||||
elif "tools" in params:
|
||||
del params["tools"]
|
||||
|
||||
# LLM response via llm_complete (non-streaming)
|
||||
collected_content = ""
|
||||
collected_tool_calls: list[dict[str, Any]] = []
|
||||
try:
|
||||
result = await llm_complete(
|
||||
model=params.get("model", "gpt-4o-mini"),
|
||||
messages=params.get("messages", []),
|
||||
temperature=params.get("temperature", 0.7),
|
||||
max_tokens=params.get("max_tokens", 2048),
|
||||
api_key=params.get("api_key"),
|
||||
api_base=params.get("api_base"),
|
||||
tools=params.get("tools"),
|
||||
)
|
||||
collected_content = result["content"]
|
||||
if collected_content:
|
||||
yield f"data: {json.dumps({'type': 'token', 'content': collected_content})}\n\n"
|
||||
# Extract tool calls from raw response
|
||||
raw_response = result["raw_response"]
|
||||
if hasattr(raw_response.choices[0].message, "tool_calls") and raw_response.choices[0].message.tool_calls:
|
||||
for tc in raw_response.choices[0].message.tool_calls:
|
||||
collected_tool_calls.append({
|
||||
"id": tc.id or "",
|
||||
"function": {
|
||||
"name": tc.function.name if tc.function else "",
|
||||
"arguments": tc.function.arguments if tc.function and tc.function.arguments else "",
|
||||
},
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error("LLM error: %s", exc)
|
||||
yield f"data: {json.dumps({'type': 'error', 'content': str(exc)})}\n\n"
|
||||
await save_message(db, session.id, "assistant", f"Error: {exc}", tenant_id, model_used=model_id)
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
# If tool calls, execute them and continue loop
|
||||
if collected_tool_calls:
|
||||
# Save assistant message with tool calls
|
||||
await save_message(
|
||||
db, session.id, "assistant", collected_content, tenant_id,
|
||||
tool_calls=collected_tool_calls, model_used=model_id,
|
||||
)
|
||||
yield f"data: {json.dumps({'type': 'tool_calls', 'tools': [tc['function']['name'] for tc in collected_tool_calls]})}\n\n"
|
||||
|
||||
# Execute each tool call
|
||||
for tc in collected_tool_calls:
|
||||
tool_name = tc["function"]["name"]
|
||||
try:
|
||||
tool_args = json.loads(tc["function"]["arguments"])
|
||||
except json.JSONDecodeError:
|
||||
tool_args = {}
|
||||
|
||||
tool = registry.get(tool_name)
|
||||
if tool is None:
|
||||
result = f"Tool '{tool_name}' not found"
|
||||
else:
|
||||
result = await execute_tool_call(tool, tool_args, user_context)
|
||||
|
||||
yield f"data: {json.dumps({'type': 'tool_result', 'tool': tool_name, 'result': result[:500]})}\n\n"
|
||||
|
||||
# Add tool result to messages for next iteration
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": collected_content,
|
||||
"tool_calls": collected_tool_calls,
|
||||
})
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc["id"],
|
||||
"name": tool_name,
|
||||
"content": result,
|
||||
})
|
||||
|
||||
# Update params with new messages for next iteration
|
||||
params, model_id = await build_litellm_params(db, agent, messages, tenant_id)
|
||||
continue
|
||||
|
||||
# No tool calls — final response
|
||||
await save_message(db, session.id, "assistant", collected_content, tenant_id, model_used=model_id)
|
||||
await db.commit()
|
||||
yield f"data: {json.dumps({'type': 'done', 'content': collected_content})}\n\n"
|
||||
return
|
||||
|
||||
# Max iterations reached
|
||||
await save_message(db, session.id, "assistant", collected_content, tenant_id, model_used=model_id)
|
||||
await db.commit()
|
||||
yield f"data: {json.dumps({'type': 'done', 'content': collected_content})}\n\n"
|
||||
|
||||
|
||||
# ─── Seed Defaults ───
|
||||
|
||||
async def seed_defaults(db: AsyncSession) -> None:
|
||||
|
||||
@@ -1,121 +1,19 @@
|
||||
"""Global tool registry for AI Assistant plugin tools.
|
||||
"""Compatibility shim — the tool registry moved to the core AI layer.
|
||||
|
||||
Plugins can register tools that AI agents can call during chat sessions.
|
||||
Each tool declares a name, description, JSON schema for parameters,
|
||||
and an async handler. Tools can optionally require specific RBAC permissions.
|
||||
The agent runtime must not depend on this optional plugin being active,
|
||||
so ``ToolRegistry`` / ``AITool`` / ``get_tool_registry`` now live in
|
||||
``app.ai.tool_registry``. Import from here still works for existing
|
||||
plugin code; new code should import from ``app.ai.tool_registry``
|
||||
directly (or via the ai_assistant contract).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
from app.ai.tool_registry import ( # noqa: F401
|
||||
AITool,
|
||||
ToolHandler,
|
||||
ToolRegistry,
|
||||
get_tool_registry,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToolHandler(Protocol):
|
||||
async def __call__(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
) -> str: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class AITool:
|
||||
"""Represents a tool that an AI agent can call."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: dict[str, Any] # JSON Schema for parameters
|
||||
handler: ToolHandler
|
||||
plugin_name: str = ""
|
||||
required_permission: str | None = None # e.g. "mail:send"
|
||||
category: str = "general"
|
||||
|
||||
def to_openai_schema(self) -> dict[str, Any]:
|
||||
"""Convert to OpenAI function-calling tool schema."""
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": self.parameters,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Singleton registry for AI tools."""
|
||||
|
||||
_instance: ToolRegistry | None = None
|
||||
|
||||
def __new__(cls) -> ToolRegistry:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._tools: dict[str, AITool] = {}
|
||||
return cls._instance
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
parameters: dict[str, Any],
|
||||
handler: ToolHandler,
|
||||
plugin_name: str = "",
|
||||
required_permission: str | None = None,
|
||||
category: str = "general",
|
||||
) -> None:
|
||||
"""Register a tool."""
|
||||
tool = AITool(
|
||||
name=name,
|
||||
description=description,
|
||||
parameters=parameters,
|
||||
handler=handler,
|
||||
plugin_name=plugin_name,
|
||||
required_permission=required_permission,
|
||||
category=category,
|
||||
)
|
||||
self._tools[name] = tool
|
||||
logger.info("AI tool registered: %s (plugin=%s)", name, plugin_name)
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""Unregister a tool by name."""
|
||||
self._tools.pop(name, None)
|
||||
|
||||
def unregister_plugin(self, plugin_name: str) -> None:
|
||||
"""Unregister all tools from a plugin."""
|
||||
to_remove = [
|
||||
name for name, tool in self._tools.items() if tool.plugin_name == plugin_name
|
||||
]
|
||||
for name in to_remove:
|
||||
self._tools.pop(name, None)
|
||||
|
||||
def get(self, name: str) -> AITool | None:
|
||||
return self._tools.get(name)
|
||||
|
||||
def get_all(self) -> list[AITool]:
|
||||
return list(self._tools.values())
|
||||
|
||||
def get_by_names(self, names: list[str]) -> list[AITool]:
|
||||
return [self._tools[name] for name in names if name in self._tools]
|
||||
|
||||
def list_for_api(self) -> list[dict[str, Any]]:
|
||||
"""Return tool list for API response."""
|
||||
return [
|
||||
{
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters,
|
||||
"plugin_name": tool.plugin_name,
|
||||
"required_permission": tool.required_permission,
|
||||
"category": tool.category,
|
||||
}
|
||||
for tool in self._tools.values()
|
||||
]
|
||||
|
||||
|
||||
def get_tool_registry() -> ToolRegistry:
|
||||
"""Get the global tool registry singleton."""
|
||||
return ToolRegistry()
|
||||
__all__ = ["AITool", "ToolHandler", "ToolRegistry", "get_tool_registry"]
|
||||
|
||||
@@ -64,7 +64,7 @@ class ProactiveSuggestion(Base, TenantMixin, OwnedMixin):
|
||||
)
|
||||
|
||||
|
||||
class ContextLog(Base, TenantMixin):
|
||||
class ContextLog(Base, TenantMixin, OwnedMixin):
|
||||
"""Log of user context changes (page views, entity selections)."""
|
||||
|
||||
__tablename__ = "ai_proactive_context_log"
|
||||
@@ -86,7 +86,7 @@ class ContextLog(Base, TenantMixin):
|
||||
)
|
||||
|
||||
|
||||
class ProactiveSettings(Base, TenantMixin):
|
||||
class ProactiveSettings(Base, TenantMixin, OwnedMixin):
|
||||
"""Per-user settings for proactive AI."""
|
||||
|
||||
__tablename__ = "ai_proactive_settings"
|
||||
|
||||
@@ -62,10 +62,77 @@ def get_sse_queue(user_id: str) -> asyncio.Queue[dict[str, Any]]:
|
||||
|
||||
|
||||
async def push_suggestion(user_id: str, suggestion: dict[str, Any]) -> None:
|
||||
"""Push suggestion to user's SSE queue."""
|
||||
"""Push suggestion to user's SSE queue and post to Communication."""
|
||||
queue = get_sse_queue(user_id)
|
||||
await queue.put(suggestion)
|
||||
|
||||
# Post suggestion to Communication (I-WORK-PROACTIVE)
|
||||
try:
|
||||
import uuid as uuid_mod
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
from app.plugins.builtins.kommunikation.models import CommConversation
|
||||
from sqlalchemy import select as sa_select
|
||||
from app.core.db import get_worker_session_factory
|
||||
komm = get_contract_registry().get("kommunikation")
|
||||
if komm:
|
||||
factory = get_worker_session_factory()
|
||||
async with factory() as db:
|
||||
# Find or create AI suggestions room
|
||||
room_title = "KI Vorschläge"
|
||||
# Get tenant_id from suggestion or user
|
||||
tenant_id = suggestion.get("tenant_id")
|
||||
if not tenant_id:
|
||||
return
|
||||
existing = await db.execute(
|
||||
sa_select(CommConversation).where(
|
||||
CommConversation.tenant_id == uuid_mod.UUID(str(tenant_id)),
|
||||
CommConversation.title == room_title,
|
||||
CommConversation.is_locked.is_(True),
|
||||
CommConversation.locked_by == "ai_proactive",
|
||||
CommConversation.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
conv = existing.scalar_one_or_none()
|
||||
if not conv:
|
||||
room = await komm.create_plugin_room(
|
||||
db=db,
|
||||
tenant_id=uuid_mod.UUID(str(tenant_id)),
|
||||
user_id=uuid_mod.UUID(str(user_id)),
|
||||
plugin_name="ai_proactive",
|
||||
title=room_title,
|
||||
participant_type="ai",
|
||||
)
|
||||
conv_id = uuid_mod.UUID(room["conversation_id"])
|
||||
else:
|
||||
conv_id = conv.id
|
||||
await komm.send_message(
|
||||
db=db,
|
||||
tenant_id=uuid_mod.UUID(str(tenant_id)),
|
||||
conversation_id=conv_id,
|
||||
sender_id=None,
|
||||
sender_type="ai",
|
||||
content=suggestion.get("title", "KI Vorschlag"),
|
||||
content_format="text",
|
||||
blocks=[
|
||||
{
|
||||
"block_type": "action_card",
|
||||
"block_data": {
|
||||
"title": suggestion.get("title", "Vorschlag"),
|
||||
"description": suggestion.get("description", ""),
|
||||
"actions": [
|
||||
{"label": "Annehmen", "action": "accept_suggestion", "data": {"suggestion_id": suggestion.get("id", "")}},
|
||||
{"label": "Ablehnen", "action": "dismiss_suggestion", "data": {"suggestion_id": suggestion.get("id", "")}},
|
||||
],
|
||||
},
|
||||
"sort_order": 0,
|
||||
}
|
||||
],
|
||||
metadata={"suggestion_id": suggestion.get("id", ""), "type": "proactive_suggestion"},
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.warning("Failed to post suggestion to communication", exc_info=True)
|
||||
|
||||
|
||||
# ─── Rate Limiting ───
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ async def send_agent_message(
|
||||
|
||||
# 2. Create a kommunikation message in a dedicated agent room
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.contracts import Message, Room
|
||||
from app.plugins.builtins.kommunikation.contracts import CommConversation as Room, CommMessage as Message
|
||||
|
||||
# Find or create the agent-to-agent room
|
||||
room_name = f"agent:{from_agent_id}:{target_agent.id}"
|
||||
|
||||
@@ -11,6 +11,7 @@ from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
@@ -59,6 +60,13 @@ def _agent_to_response(a: AgentDefinition) -> AgentDefinitionResponse:
|
||||
max_executions_per_hour=a.max_executions_per_hour,
|
||||
max_duration_seconds=a.max_duration_seconds,
|
||||
budget_limit_usd=a.budget_limit_usd,
|
||||
temperature=a.temperature,
|
||||
max_tokens=a.max_tokens,
|
||||
max_steps=a.max_steps,
|
||||
trace_mode=a.trace_mode,
|
||||
skill_ids=[str(s) for s in (a.skill_ids or [])],
|
||||
trigger_config=a.trigger_config or {},
|
||||
ai_use_case_metadata=a.ai_use_case_metadata or {},
|
||||
created_by=str(a.created_by) if a.created_by else None,
|
||||
created_at=a.created_at.isoformat() if a.created_at else None,
|
||||
updated_at=a.updated_at.isoformat() if a.updated_at else None,
|
||||
@@ -251,6 +259,93 @@ async def update_agent(
|
||||
return _agent_to_response(agent)
|
||||
|
||||
|
||||
# ─── AI Use-Case Metadata ───
|
||||
|
||||
|
||||
class AIUseCaseMetadataUpdate(BaseModel):
|
||||
"""Update AI use-case metadata for an agent."""
|
||||
|
||||
intended_purpose: str | None = None
|
||||
owner: str | None = None
|
||||
data_categories: list[str] | None = None
|
||||
allowed_providers: list[str] | None = None
|
||||
allowed_models: list[str] | None = None
|
||||
allowed_actions: list[str] | None = None
|
||||
oversight_policy: str | None = None
|
||||
risk_class: str | None = None
|
||||
human_review_required: bool | None = None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{agent_id}/ai-use-case",
|
||||
dependencies=[Depends(require_permission("agents:read"))],
|
||||
)
|
||||
async def get_ai_use_case(
|
||||
agent_id: str,
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get AI use-case metadata for an agent."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
aid = uuid.UUID(agent_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
agent = await AgentService.get_by_id(db, tenant_id, aid)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return {"agent_id": agent_id, "ai_use_case_metadata": agent.ai_use_case_metadata or {}}
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{agent_id}/ai-use-case",
|
||||
dependencies=[Depends(require_permission("agents:write"))],
|
||||
)
|
||||
async def update_ai_use_case(
|
||||
agent_id: str,
|
||||
data: AIUseCaseMetadataUpdate,
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update AI use-case metadata for an agent."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
aid = uuid.UUID(agent_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
agent = await AgentService.get_by_id(db, tenant_id, aid)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
|
||||
# Merge with existing metadata (partial update).
|
||||
current = dict(agent.ai_use_case_metadata or {})
|
||||
updates = data.model_dump(exclude_none=True)
|
||||
current.update(updates)
|
||||
|
||||
# Validate the merged metadata against the agent config.
|
||||
from app.ai.ai_use_case import AIUseCaseMetadata, validate_ai_use_case
|
||||
|
||||
try:
|
||||
metadata = AIUseCaseMetadata(**current)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid AI use-case metadata: {e}") from None
|
||||
warnings = validate_ai_use_case(metadata, agent)
|
||||
|
||||
updated = await AgentService.update(
|
||||
db, tenant_id, aid, {"ai_use_case_metadata": current}, user_id=user_id
|
||||
)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return {
|
||||
"agent_id": agent_id,
|
||||
"ai_use_case_metadata": current,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{agent_id}",
|
||||
dependencies=[Depends(require_permission("agents:delete"))],
|
||||
@@ -506,3 +601,55 @@ async def send_agent_message_endpoint(
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
# ─── Punkt 7: SSE Streaming Endpoint (agent_stream.py) ─────────────────────
|
||||
|
||||
|
||||
@router.post("/{id}/stream")
|
||||
async def stream_agent_run(
|
||||
id: str,
|
||||
body: AgentMessageRequest,
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Stream an agent run via Server-Sent Events (SSE).
|
||||
|
||||
Uses ``app.ai.agent_stream.stream_react_loop`` to emit step events
|
||||
in real-time as the agent processes.
|
||||
"""
|
||||
from fastapi.responses import StreamingResponse
|
||||
from app.ai.agent_stream import stream_react_loop
|
||||
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
||||
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
aid = uuid.UUID(id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
agent = await AgentService.get_by_id(db, tenant_id, aid)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
if not agent.is_active:
|
||||
raise HTTPException(status_code=400, detail="Agent is not active")
|
||||
|
||||
registry = get_tool_registry()
|
||||
tool_ids: list[str] = list(agent.tool_ids or [])
|
||||
tools = registry.get_by_names(tool_ids) if tool_ids else []
|
||||
tool_schemas = [t.to_openai_schema() for t in tools] if tools else []
|
||||
|
||||
return StreamingResponse(
|
||||
stream_react_loop(
|
||||
agent_definition=agent,
|
||||
user_message=body.message,
|
||||
tools=tool_schemas,
|
||||
tool_registry=registry,
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
agent_run_id=aid,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
@@ -5,25 +5,22 @@ Safety features:
|
||||
- Max duration per execution (asyncio timeout)
|
||||
- Auto-stop on infinite loop (same tool called 5x consecutively)
|
||||
- Budget limit per agent (track cumulative cost_usd, stop if over budget)
|
||||
- ReAct loop with structured Thought/Action/Observation step tracking
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.ai.llm_client import llm_complete
|
||||
from app.ai.agent_loop import ReActResult, run_react_loop
|
||||
from app.core.db import get_session_factory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Track consecutive tool calls per agent run to detect infinite loops
|
||||
_consecutive_tool_calls: dict[str, dict[str, int]] = {} # run_id -> {tool_name: count}
|
||||
|
||||
|
||||
async def run_agent(
|
||||
ctx: dict[str, Any],
|
||||
@@ -32,16 +29,20 @@ async def run_agent(
|
||||
trigger_data: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""ARQ job function. Loads AgentDefinition from DB, checks rate limits,
|
||||
gathers context, calls LLM via LiteLLM, executes tool calls via ToolRegistry,
|
||||
saves result to AgentRun. Returns result dict.
|
||||
gathers context, runs the ReAct loop, saves steps and result to AgentRun.
|
||||
|
||||
Safety checks:
|
||||
1. Rate limit: max_executions_per_hour
|
||||
2. Max duration: max_duration_seconds (asyncio.timeout)
|
||||
3. Infinite loop: same tool 5x consecutively
|
||||
3. Infinite loop: same tool 5x consecutively (handled in ReAct loop)
|
||||
4. Budget limit: cumulative cost_usd
|
||||
"""
|
||||
from app.plugins.builtins.automation.models import AgentDefinition, AgentRun
|
||||
from app.plugins.builtins.automation.models import (
|
||||
AgentDefinition,
|
||||
AgentRun,
|
||||
AgentRunStep,
|
||||
)
|
||||
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
||||
|
||||
factory = get_session_factory()
|
||||
|
||||
@@ -61,10 +62,6 @@ async def run_agent(
|
||||
return {"error": "Agent is inactive", "status": "skipped"}
|
||||
|
||||
# ── Safety Check 1: Rate Limit ──
|
||||
# Uses DB-based counting (AgentRun rows in last hour) rather than
|
||||
# check_rate_limit() because this counts actual executions per agent,
|
||||
# not just attempts. This is more accurate for per-agent execution caps
|
||||
# and respects the agent-specific max_executions_per_hour setting.
|
||||
if agent.max_executions_per_hour:
|
||||
async with factory() as db:
|
||||
one_hour_ago = datetime.now(UTC)
|
||||
@@ -102,7 +99,6 @@ async def run_agent(
|
||||
# Gather context
|
||||
context_data: dict[str, Any] = {}
|
||||
if trigger_type == "proactive" or agent.mode == "proactive":
|
||||
# Collect context data (recent contacts, mails, events)
|
||||
try:
|
||||
from app.services.contact_service import list_contacts
|
||||
async with factory() as db:
|
||||
@@ -112,8 +108,22 @@ async def run_agent(
|
||||
logger.warning("Failed to collect contacts for proactive context")
|
||||
|
||||
try:
|
||||
from app.core.cache import get_cached_mail_summary
|
||||
context_data["recent_mails"] = get_cached_mail_summary(agent.tenant_id) or []
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
mail_contract = get_contract("mail")
|
||||
if mail_contract and hasattr(mail_contract, "get_recent_mails"):
|
||||
from sqlalchemy import select as _select
|
||||
from app.plugins.builtins.mail.models import Mail
|
||||
async with factory() as db:
|
||||
mail_q = await db.execute(
|
||||
_select(Mail)
|
||||
.where(Mail.tenant_id == agent.tenant_id)
|
||||
.order_by(Mail.date.desc())
|
||||
.limit(5)
|
||||
)
|
||||
context_data["recent_mails"] = [
|
||||
{"id": str(m.id), "subject": m.subject, "from": m.sender}
|
||||
for m in mail_q.scalars()
|
||||
]
|
||||
except Exception:
|
||||
logger.warning("Failed to collect mails for proactive context")
|
||||
|
||||
@@ -125,7 +135,6 @@ async def run_agent(
|
||||
except Exception:
|
||||
logger.warning("Failed to collect events for proactive context")
|
||||
else:
|
||||
# Reactive mode: use trigger_data as context
|
||||
context_data = trigger_data or {}
|
||||
|
||||
# ── Lifecycle: before run ──
|
||||
@@ -143,145 +152,309 @@ async def run_agent(
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Call LLM via LiteLLM
|
||||
# ── Prepare tools ──
|
||||
registry = get_tool_registry()
|
||||
tool_ids: list[str] = list(agent.tool_ids or [])
|
||||
tools = registry.get_by_names(tool_ids) if tool_ids else []
|
||||
tool_schemas = [t.to_openai_schema() for t in tools] if tools else []
|
||||
|
||||
# ── Resolve agent permissions (Punkt 2+3 der Audit) ──
|
||||
from app.ai.agent_permissions import resolve_agent_permissions
|
||||
from app.ai.agent_tools import get_agent_tools
|
||||
from app.ai.skill_registry import get_skill_registry
|
||||
|
||||
async with factory() as db:
|
||||
perm_ctx = await resolve_agent_permissions(
|
||||
db=db,
|
||||
tenant_id=agent.tenant_id,
|
||||
user_id=agent.created_by or uuid_mod.uuid4(),
|
||||
agent_definition=agent,
|
||||
)
|
||||
|
||||
# Use permission-filtered tools instead of raw tool_ids
|
||||
skill_reg = get_skill_registry()
|
||||
tool_schemas, _skills = get_agent_tools(
|
||||
agent_definition=agent,
|
||||
tool_registry=registry,
|
||||
skill_registry=skill_reg,
|
||||
user_permissions=perm_ctx.user_permissions,
|
||||
)
|
||||
|
||||
# ── Create AgentRun record ──
|
||||
run_id: uuid.UUID | None = None
|
||||
started_at = datetime.now(UTC)
|
||||
async with factory() as db:
|
||||
run = AgentRun(
|
||||
tenant_id=agent.tenant_id,
|
||||
agent_id=agent.id,
|
||||
status="running",
|
||||
started_at=started_at,
|
||||
trigger_type=trigger_type,
|
||||
trigger_data=context_data,
|
||||
)
|
||||
db.add(run)
|
||||
await db.flush()
|
||||
run_id = run.id
|
||||
await db.commit()
|
||||
|
||||
# ── Run ReAct loop ──
|
||||
result_data: dict[str, Any] = {
|
||||
"agent_id": str(agent.id),
|
||||
"agent_name": agent.name,
|
||||
"trigger_type": trigger_type,
|
||||
"status": "running",
|
||||
"run_id": str(run_id) if run_id else None,
|
||||
"llm_response": None,
|
||||
"tool_calls": [],
|
||||
"cost_usd": 0.0,
|
||||
"steps": [],
|
||||
"error": None,
|
||||
}
|
||||
|
||||
# ── Safety Check 3: Max Duration ──
|
||||
max_duration = agent.max_duration_seconds or 300
|
||||
|
||||
try:
|
||||
async def _run_llm() -> None:
|
||||
"""Inner coroutine for LLM call with tool execution."""
|
||||
# Build system prompt from agent configuration
|
||||
system_prompt = agent.system_prompt or "You are a helpful AI assistant."
|
||||
user_prompt = f"Context: {context_data}"
|
||||
import asyncio
|
||||
import uuid as uuid_mod
|
||||
|
||||
litellm_model = agent.model or "gpt-4o"
|
||||
if agent.provider and agent.provider != "openai":
|
||||
litellm_model = f"{agent.provider}/{litellm_model}"
|
||||
# ── Build agent context via context_builder (Punkt 1 der Audit) ──
|
||||
from app.ai.context_builder import build_agent_context
|
||||
|
||||
result = await llm_complete(
|
||||
model=litellm_model,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=agent.max_tokens or 1000,
|
||||
api_key=agent.api_key or None,
|
||||
api_base=agent.api_base or None,
|
||||
# Sanitize context_data to remove sensitive fields (Punkt 4: data_policy)
|
||||
from app.core.sensitive_data import sanitize_dict
|
||||
safe_context_data = sanitize_dict(context_data)
|
||||
|
||||
# Build the user message from sanitized context
|
||||
user_message = f"Context: {safe_context_data}" if safe_context_data else "No additional context provided."
|
||||
|
||||
# Build full message list (system prompt + context + user message)
|
||||
messages = await build_agent_context(
|
||||
agent_definition=agent,
|
||||
user_message=user_message,
|
||||
db=None, # No DB session available here; context_builder handles gracefully
|
||||
tenant_id=agent.tenant_id,
|
||||
user_id=agent.created_by or uuid_mod.uuid4(),
|
||||
)
|
||||
|
||||
# ── Enforce data policy: filter sensitive fields from messages (Punkt 4) ──
|
||||
from app.ai.data_policy import enforce_data_policy
|
||||
messages = await enforce_data_policy(
|
||||
db=None,
|
||||
tenant_id=agent.tenant_id,
|
||||
messages=messages,
|
||||
agent_definition=agent,
|
||||
)
|
||||
|
||||
react_result: ReActResult = await asyncio.wait_for(
|
||||
run_react_loop(
|
||||
agent_definition=agent,
|
||||
messages=messages,
|
||||
tools=tool_schemas,
|
||||
tool_registry=registry,
|
||||
db=None, # ReAct loop doesn't need DB session for LLM calls directly
|
||||
tenant_id=agent.tenant_id,
|
||||
user_id=agent.created_by or uuid_mod.uuid4(),
|
||||
agent_run_id=run_id,
|
||||
max_steps=20,
|
||||
timeout_seconds=max_duration,
|
||||
require_approval=bool(getattr(agent, "require_approval", False)),
|
||||
approval_tools=getattr(agent, "approval_tools", None),
|
||||
),
|
||||
timeout=max_duration + 10, # Extra buffer beyond loop's own timeout
|
||||
)
|
||||
|
||||
result_data["status"] = react_result.status
|
||||
result_data["llm_response"] = react_result.final_content
|
||||
result_data["cost_usd"] = react_result.total_cost_usd
|
||||
result_data["error"] = react_result.error
|
||||
|
||||
# ── Mark result as AI-generated (Punkt 6: transparency) ──
|
||||
from app.ai.transparency import mark_as_ai_generated
|
||||
if react_result.final_content:
|
||||
ai_metadata = mark_as_ai_generated(
|
||||
react_result.final_content,
|
||||
metadata={
|
||||
"model": getattr(agent, "llm_model", "unknown"),
|
||||
"provider": getattr(agent, "provider", "unknown"),
|
||||
"agent_id": str(agent.id),
|
||||
"agent_name": agent.name,
|
||||
"run_id": str(run_id) if run_id else None,
|
||||
},
|
||||
)
|
||||
content = result["content"]
|
||||
result_data["ai_generated"] = True
|
||||
result_data["ai_metadata"] = ai_metadata.get("ai_metadata", {})
|
||||
|
||||
# Track cost
|
||||
result_data["cost_usd"] = result["cost_usd"]
|
||||
|
||||
result_data["llm_response"] = content
|
||||
|
||||
# Execute tool calls if LLM returned function calls
|
||||
raw_response = result["raw_response"]
|
||||
if hasattr(raw_response.choices[0].message, "tool_calls") and raw_response.choices[0].message.tool_calls:
|
||||
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
||||
|
||||
registry = get_tool_registry()
|
||||
tool_call_count: dict[str, int] = {}
|
||||
for tc in raw_response.choices[0].message.tool_calls:
|
||||
tool_name = tc.function.name
|
||||
# ── Safety Check 4: Infinite Loop Detection ──
|
||||
tool_call_count[tool_name] = tool_call_count.get(tool_name, 0) + 1
|
||||
if tool_call_count[tool_name] >= 5:
|
||||
logger.warning(
|
||||
"Infinite loop detected for agent %s: tool '%s' called %d times consecutively",
|
||||
agent.id, tool_name, tool_call_count[tool_name],
|
||||
)
|
||||
result_data["error"] = f"Infinite loop detected: tool '{tool_name}' called 5+ times consecutively"
|
||||
result_data["status"] = "failed"
|
||||
return
|
||||
|
||||
tool = registry.get(tool_name)
|
||||
if tool:
|
||||
try:
|
||||
import json
|
||||
args = json.loads(tc.function.arguments)
|
||||
tool_result = await tool.handler(
|
||||
arguments=args,
|
||||
context={"tenant_id": str(agent.tenant_id)},
|
||||
)
|
||||
result_data["tool_calls"].append({
|
||||
"tool": tool_name,
|
||||
"arguments": args,
|
||||
"result": tool_result,
|
||||
})
|
||||
except Exception as e:
|
||||
result_data["tool_calls"].append({
|
||||
"tool": tool_name,
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
result_data["status"] = "completed"
|
||||
|
||||
# Run with timeout
|
||||
try:
|
||||
await asyncio.wait_for(_run_llm(), timeout=max_duration)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"Agent %s execution timed out after %d seconds",
|
||||
agent.id, max_duration,
|
||||
)
|
||||
result_data["status"] = "timed_out"
|
||||
result_data["error"] = f"Execution timed out after {max_duration} seconds"
|
||||
# ── Create oversight decision record (Punkt 5: oversight) ──
|
||||
from app.ai.oversight import DecisionRecord, create_decision_record
|
||||
try:
|
||||
async with factory() as db:
|
||||
record = DecisionRecord(
|
||||
agent_run_id=run_id or uuid_mod.uuid4(),
|
||||
recommendation=react_result.final_content,
|
||||
evidence={
|
||||
"steps": len(react_result.steps),
|
||||
"cost_usd": react_result.total_cost_usd,
|
||||
"status": react_result.status,
|
||||
},
|
||||
)
|
||||
await create_decision_record(db, agent.tenant_id, record)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to create oversight decision record: %s", e)
|
||||
result_data["steps"] = [
|
||||
{
|
||||
"step_number": s.step_number,
|
||||
"thought": s.thought,
|
||||
"action": s.action,
|
||||
"action_input": s.action_input,
|
||||
"observation": s.observation,
|
||||
"cost_usd": s.cost_usd,
|
||||
}
|
||||
for s in react_result.steps
|
||||
]
|
||||
result_data["tool_calls"] = [
|
||||
{"tool": s.action, "arguments": s.action_input, "result": s.observation}
|
||||
for s in react_result.steps if s.action
|
||||
]
|
||||
|
||||
except TimeoutError:
|
||||
logger.warning("Agent %s execution timed out after %d seconds", agent.id, max_duration)
|
||||
result_data["status"] = "stopped_timeout"
|
||||
result_data["error"] = f"Execution timed out after {max_duration} seconds"
|
||||
except Exception as e:
|
||||
logger.exception("Agent run failed for %s", agent.id)
|
||||
result_data["status"] = "failed"
|
||||
result_data["status"] = "stopped_error"
|
||||
result_data["error"] = str(e)
|
||||
|
||||
# ── Save steps to DB ──
|
||||
completed_at = datetime.now(UTC)
|
||||
duration_seconds = (completed_at - started_at).total_seconds()
|
||||
|
||||
try:
|
||||
async with factory() as db:
|
||||
# Save each step
|
||||
for step_data in result_data.get("steps", []):
|
||||
step = AgentRunStep(
|
||||
tenant_id=agent.tenant_id,
|
||||
agent_run_id=run_id,
|
||||
step_number=step_data["step_number"],
|
||||
thought=step_data.get("thought"),
|
||||
action=step_data.get("action"),
|
||||
action_input=step_data.get("action_input"),
|
||||
observation=step_data.get("observation"),
|
||||
cost_usd=step_data.get("cost_usd", 0.0),
|
||||
)
|
||||
db.add(step)
|
||||
|
||||
# Update AgentRun with final results
|
||||
run_result = await db.execute(
|
||||
select(AgentRun).where(AgentRun.id == run_id)
|
||||
)
|
||||
run = run_result.scalar_one_or_none()
|
||||
if run:
|
||||
run.status = result_data["status"]
|
||||
run.completed_at = completed_at
|
||||
run.duration_seconds = duration_seconds
|
||||
run.result = result_data.get("llm_response")
|
||||
run.error = result_data.get("error")
|
||||
run.cost_usd = result_data.get("cost_usd", 0.0)
|
||||
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.exception("Failed to save agent run steps for %s", agent.id)
|
||||
result_data["save_error"] = str(e)
|
||||
|
||||
# ── Lifecycle: after run ──
|
||||
from app.core.hooks import do_action
|
||||
await do_action("agent.after_run", agent_id=str(agent.id), tenant_id=str(agent.tenant_id), status=result_data.get("status"), result=result_data)
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
await do_action(
|
||||
"agent.after_run",
|
||||
agent_id=str(agent.id),
|
||||
tenant_id=str(agent.tenant_id),
|
||||
status=result_data.get("status"),
|
||||
result=result_data,
|
||||
)
|
||||
|
||||
# ── Post agent result to Communication (F-COMM) ──
|
||||
try:
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
komm = get_contract_registry().get("kommunikation")
|
||||
if komm:
|
||||
async with factory() as db:
|
||||
# Find or create agent conversation room
|
||||
from app.plugins.builtins.contracts import get_contract as _get_contract
|
||||
_komm_contract = _get_contract("kommunikation")
|
||||
from app.plugins.builtins.kommunikation.models import CommConversation
|
||||
from sqlalchemy import select as sa_select
|
||||
room_title = f"Agent: {agent.name}"
|
||||
existing = await db.execute(
|
||||
sa_select(CommConversation).where(
|
||||
CommConversation.tenant_id == agent.tenant_id,
|
||||
CommConversation.title == room_title,
|
||||
CommConversation.is_locked.is_(True),
|
||||
CommConversation.locked_by == "automation",
|
||||
CommConversation.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
conv = existing.scalar_one_or_none()
|
||||
if not conv:
|
||||
room = await komm.create_plugin_room(
|
||||
db=db,
|
||||
tenant_id=agent.tenant_id,
|
||||
user_id=agent.created_by,
|
||||
plugin_name="automation",
|
||||
title=room_title,
|
||||
participant_type="agent",
|
||||
)
|
||||
conv_id = uuid.UUID(room["conversation_id"])
|
||||
else:
|
||||
conv_id = conv.id
|
||||
|
||||
# Post result as message with action_card block
|
||||
status = result_data.get("status", "unknown")
|
||||
result_text = result_data.get("llm_response", result_data.get("error", "No result"))
|
||||
await komm.send_message(
|
||||
db=db,
|
||||
tenant_id=agent.tenant_id,
|
||||
conversation_id=conv_id,
|
||||
sender_id=agent.id,
|
||||
sender_type="agent",
|
||||
content=f"Agent '{agent.name}' completed with status: {status}",
|
||||
content_format="text",
|
||||
blocks=[
|
||||
{
|
||||
"block_type": "action_card",
|
||||
"block_data": {
|
||||
"title": f"Agent Result: {agent.name}",
|
||||
"description": result_text[:500] if result_text else "No result",
|
||||
"actions": [
|
||||
{"label": "View Details", "action": "view_agent_run", "data": {"run_id": str(run_id)}},
|
||||
],
|
||||
},
|
||||
"sort_order": 0,
|
||||
}
|
||||
],
|
||||
metadata={"agent_id": str(agent.id), "run_id": str(run_id), "status": status},
|
||||
)
|
||||
await db.commit()
|
||||
logger.info("Posted agent result to conversation %s", conv_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to post agent result to communication: %s", e)
|
||||
|
||||
async with factory() as db:
|
||||
await enqueue_outbox_event(
|
||||
db,
|
||||
agent.tenant_id,
|
||||
'agent.run_completed',
|
||||
{'agent_id': str(agent.id), 'tenant_id': str(agent.tenant_id), 'status': result_data.get('status'), 'cost_usd': result_data.get('cost_usd', 0.0)},
|
||||
{
|
||||
'agent_id': str(agent.id),
|
||||
'tenant_id': str(agent.tenant_id),
|
||||
'status': result_data.get('status'),
|
||||
'cost_usd': result_data.get('cost_usd', 0.0),
|
||||
},
|
||||
aggregate_type='agent',
|
||||
aggregate_id=agent.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Save result to AgentRun
|
||||
try:
|
||||
async with factory() as db:
|
||||
run = AgentRun(
|
||||
tenant_id=agent.tenant_id,
|
||||
agent_id=agent.id,
|
||||
trigger_type=trigger_type,
|
||||
status=result_data["status"],
|
||||
input_data=context_data,
|
||||
output_data=result_data.get("llm_response"),
|
||||
tool_calls=result_data.get("tool_calls", []),
|
||||
cost_usd=result_data.get("cost_usd", 0.0),
|
||||
error_message=result_data.get("error"),
|
||||
duration_seconds=None,
|
||||
)
|
||||
db.add(run)
|
||||
await db.flush()
|
||||
result_data["run_id"] = str(run.id)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to save AgentRun for %s", agent.id)
|
||||
result_data["save_error"] = str(e)
|
||||
|
||||
return result_data
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,11 @@ class AutomationContract:
|
||||
# ─── agent_comm ───
|
||||
send_agent_message = staticmethod(send_agent_message)
|
||||
|
||||
@classmethod
|
||||
def get_function(cls, name: str):
|
||||
"""Return a callable exposed by this contract, or None if absent."""
|
||||
return getattr(cls, name, None)
|
||||
|
||||
|
||||
# ─── self-registration ───
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Skill Definitions for AI Agent Skills (Phase F-SKILL)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS automation_skill_definitions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL,
|
||||
instructions TEXT NOT NULL,
|
||||
allowed_tool_ids JSONB NOT NULL DEFAULT '[]',
|
||||
context_policy JSONB,
|
||||
category VARCHAR(100) NOT NULL DEFAULT 'general',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_skill_defs_tenant_active ON automation_skill_definitions (tenant_id, is_active);
|
||||
CREATE INDEX IF NOT EXISTS ix_skill_defs_tenant_category ON automation_skill_definitions (tenant_id, category);
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
@@ -61,12 +62,25 @@ class AgentDefinition(Base, TenantMixin, OwnedMixin):
|
||||
budget_limit_usd: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=1.0
|
||||
)
|
||||
temperature: Mapped[float] = mapped_column(Float, nullable=False, default=0.3)
|
||||
max_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=1000)
|
||||
max_steps: Mapped[int] = mapped_column(Integer, nullable=False, default=20)
|
||||
trace_mode: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="standard"
|
||||
)
|
||||
skill_ids: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
trigger_config: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB, nullable=False, default=dict
|
||||
)
|
||||
ai_use_case_metadata: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB, nullable=False, default=dict
|
||||
)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class AgentVersion(Base, TenantMixin):
|
||||
class AgentVersion(Base, TenantMixin, OwnedMixin):
|
||||
"""Versioned snapshots of agent definitions."""
|
||||
|
||||
__tablename__ = "automation_agent_versions"
|
||||
@@ -121,7 +135,7 @@ class AutomationDefinition(Base, TenantMixin, OwnedMixin):
|
||||
)
|
||||
|
||||
|
||||
class AutomationVersion(Base, TenantMixin):
|
||||
class AutomationVersion(Base, TenantMixin, OwnedMixin):
|
||||
"""Versioned snapshots of automation definitions."""
|
||||
|
||||
__tablename__ = "automation_versions"
|
||||
@@ -146,7 +160,7 @@ class AutomationVersion(Base, TenantMixin):
|
||||
)
|
||||
|
||||
|
||||
class AutomationCronJob(Base, TenantMixin):
|
||||
class AutomationCronJob(Base, TenantMixin, OwnedMixin):
|
||||
"""Cron job schedule entries for agent heartbeats, automation triggers, or custom jobs."""
|
||||
|
||||
__tablename__ = "automation_cron_jobs"
|
||||
@@ -176,7 +190,7 @@ class AutomationCronJob(Base, TenantMixin):
|
||||
)
|
||||
|
||||
|
||||
class AgentRun(Base, TenantMixin):
|
||||
class AgentRun(Base, TenantMixin, OwnedMixin):
|
||||
"""Execution log for agent runs."""
|
||||
|
||||
__tablename__ = "automation_agent_runs"
|
||||
@@ -215,7 +229,7 @@ class AgentRun(Base, TenantMixin):
|
||||
)
|
||||
|
||||
|
||||
class AutomationRun(Base, TenantMixin):
|
||||
class AutomationRun(Base, TenantMixin, OwnedMixin):
|
||||
"""Execution log for automation runs."""
|
||||
|
||||
__tablename__ = "automation_runs"
|
||||
@@ -254,7 +268,35 @@ class AutomationRun(Base, TenantMixin):
|
||||
dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class AgentSubtask(Base, TenantMixin):
|
||||
class AgentRunStep(Base, TenantMixin, OwnedMixin):
|
||||
"""Individual step in a ReAct loop execution (Thought → Action → Observation)."""
|
||||
|
||||
__tablename__ = "automation_agent_run_steps"
|
||||
__table_args__ = (
|
||||
Index("ix_agent_run_steps_run", "tenant_id", "agent_run_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
agent_run_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("automation_agent_runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
step_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
thought: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
action: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
action_input: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
observation: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
cost_usd: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class AgentSubtask(Base, TenantMixin, OwnedMixin):
|
||||
"""A subtask delegated from one agent to another for multi-agent orchestration."""
|
||||
|
||||
__tablename__ = "agent_subtasks"
|
||||
@@ -289,3 +331,34 @@ class AgentSubtask(Base, TenantMixin):
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class SkillDefinitionDB(Base, TenantMixin, OwnedMixin):
|
||||
"""A skill definition persisted per tenant.
|
||||
|
||||
Skills are orchestration metadata, NOT a permission source. They reference
|
||||
tool IDs that the agent and the user must already be permitted to use.
|
||||
"""
|
||||
|
||||
__tablename__ = "automation_skill_definitions"
|
||||
__table_args__ = (
|
||||
Index("ix_skill_defs_tenant_active", "tenant_id", "is_active"),
|
||||
Index("ix_skill_defs_tenant_category", "tenant_id", "category"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
instructions: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
allowed_tool_ids: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
context_policy: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
category: Mapped[str] = mapped_column(String(100), nullable=False, default="general")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
@@ -50,6 +50,11 @@ class AutomationPlugin(BasePlugin):
|
||||
module="app.plugins.builtins.automation.agent_routes",
|
||||
router_attr="router",
|
||||
),
|
||||
PluginRouteDef(
|
||||
path="/api/v1/skills",
|
||||
module="app.plugins.builtins.automation.skill_routes",
|
||||
router_attr="router",
|
||||
),
|
||||
],
|
||||
events=[
|
||||
"contact.created",
|
||||
@@ -57,7 +62,7 @@ class AutomationPlugin(BasePlugin):
|
||||
"mail.received",
|
||||
"workflow.timeout",
|
||||
],
|
||||
migrations=["0001_initial.sql", "0002_agent_subtasks.sql"],
|
||||
migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.sql"],
|
||||
permissions=[
|
||||
"automation:read",
|
||||
"automation:write",
|
||||
@@ -205,6 +210,11 @@ class AutomationPlugin(BasePlugin):
|
||||
register_agent_coordinator_tools()
|
||||
except Exception:
|
||||
logger.exception("Failed to register agent coordinator tools")
|
||||
# Register workflow agent tools (I-AW: Agent→Workflow)
|
||||
try:
|
||||
self._register_workflow_agent_tools()
|
||||
except Exception:
|
||||
logger.exception("Failed to register workflow agent tools")
|
||||
# Register MiniApps from manifest
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry
|
||||
@@ -227,8 +237,142 @@ class AutomationPlugin(BasePlugin):
|
||||
logger.info("Registered own cron jobs from manifest")
|
||||
except Exception:
|
||||
logger.exception("Failed to register own cron jobs")
|
||||
# Register pre-built agents in DB (if not already present)
|
||||
try:
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
from app.plugins.builtins.automation.prebuilt.email_triage_agent import create_email_triage_agent
|
||||
from app.plugins.builtins.automation.prebuilt.contact_enrichment_agent import create_contact_enrichment_agent
|
||||
from app.plugins.builtins.automation.prebuilt.follow_up_agent import create_follow_up_agent
|
||||
from app.plugins.builtins.automation.prebuilt.report_agent import create_report_agent
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
# Get first tenant + admin user for seeding
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
tenant_result = await db.execute(sa_select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
if tenant:
|
||||
user_result = await db.execute(
|
||||
sa_select(User)
|
||||
.join(UserTenant, UserTenant.user_id == User.id)
|
||||
.where(UserTenant.tenant_id == tenant.id)
|
||||
.limit(1)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user:
|
||||
prebuilt_factories = [
|
||||
("E-Mail-Triage-Agent", create_email_triage_agent),
|
||||
("Kontakt-Anreicherungs-Agent", create_contact_enrichment_agent),
|
||||
("Follow-Up-Agent", create_follow_up_agent),
|
||||
("Berichts-Agent", create_report_agent),
|
||||
]
|
||||
for agent_name, factory in prebuilt_factories:
|
||||
# Check if agent already exists
|
||||
existing = await db.execute(
|
||||
sa_select(AgentDefinition).where(
|
||||
AgentDefinition.tenant_id == tenant.id,
|
||||
AgentDefinition.name == agent_name,
|
||||
)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
agent = factory(tenant_id=tenant.id, user_id=user.id)
|
||||
db.add(agent)
|
||||
logger.info("Registered pre-built agent '%s'", agent_name)
|
||||
await db.commit()
|
||||
logger.info("Pre-built agents registration complete")
|
||||
except Exception:
|
||||
logger.exception("Failed to register pre-built agents")
|
||||
|
||||
logger.info("Automation plugin activated")
|
||||
|
||||
def _register_workflow_agent_tools(self) -> None:
|
||||
"""Register I-AW agent tools for starting and inspecting workflows."""
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.ai.tool_registry import get_tool_registry
|
||||
registry = get_tool_registry()
|
||||
|
||||
async def _start_workflow_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Start a workflow by ID."""
|
||||
from app.services.workflow_service import create_instance
|
||||
from app.core.db import get_worker_session_factory
|
||||
workflow_id = arguments.get("workflow_id", "")
|
||||
tenant_id = context.get("tenant_id")
|
||||
user_id = context.get("user_id")
|
||||
if not workflow_id or not tenant_id:
|
||||
return {"error": "workflow_id and tenant_id required"}
|
||||
factory = get_worker_session_factory()
|
||||
async with factory() as db:
|
||||
instance = await create_instance(
|
||||
db=db,
|
||||
tenant_id=uuid.UUID(str(tenant_id)),
|
||||
workflow_id=uuid.UUID(workflow_id),
|
||||
initiated_by=uuid.UUID(str(user_id)) if user_id else None,
|
||||
)
|
||||
await db.commit()
|
||||
return {"instance_id": str(instance.get("id", "")), "status": instance.get("status", "created")}
|
||||
|
||||
registry.register(
|
||||
name="start_workflow",
|
||||
description="Start a workflow by its ID. Returns the instance ID and status.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workflow_id": {"type": "string", "description": "UUID of the workflow to start"},
|
||||
},
|
||||
"required": ["workflow_id"],
|
||||
},
|
||||
handler=_start_workflow_handler,
|
||||
plugin_name=self.manifest.name,
|
||||
required_permission="workflows:read",
|
||||
category="workflow",
|
||||
)
|
||||
|
||||
async def _check_workflow_status_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Check the status of a workflow instance."""
|
||||
from sqlalchemy import select
|
||||
from app.models.workflow import WorkflowInstance
|
||||
from app.core.db import get_worker_session_factory
|
||||
instance_id = arguments.get("instance_id", "")
|
||||
tenant_id = context.get("tenant_id")
|
||||
if not instance_id or not tenant_id:
|
||||
return {"error": "instance_id and tenant_id required"}
|
||||
factory = get_worker_session_factory()
|
||||
async with factory() as db:
|
||||
result = await db.execute(
|
||||
select(WorkflowInstance).where(
|
||||
WorkflowInstance.id == uuid.UUID(instance_id),
|
||||
WorkflowInstance.tenant_id == uuid.UUID(str(tenant_id)),
|
||||
)
|
||||
)
|
||||
inst = result.scalar_one_or_none()
|
||||
if not inst:
|
||||
return {"error": "Instance not found"}
|
||||
return {
|
||||
"instance_id": str(inst.id),
|
||||
"status": inst.status,
|
||||
"current_step": inst.current_step_index,
|
||||
"completed_at": inst.completed_at.isoformat() if inst.completed_at else None,
|
||||
}
|
||||
|
||||
registry.register(
|
||||
name="check_workflow_status",
|
||||
description="Check the status of a workflow instance by its ID.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"instance_id": {"type": "string", "description": "UUID of the workflow instance"},
|
||||
},
|
||||
"required": ["instance_id"],
|
||||
},
|
||||
handler=_check_workflow_status_handler,
|
||||
plugin_name=self.manifest.name,
|
||||
required_permission="workflows:read",
|
||||
category="workflow",
|
||||
)
|
||||
logger.info("Registered workflow agent tools: start_workflow, check_workflow_status")
|
||||
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
"""Clean up on deactivation."""
|
||||
# Contract abmelden
|
||||
@@ -250,6 +394,13 @@ class AutomationPlugin(BasePlugin):
|
||||
unregister_agent_coordinator_tools()
|
||||
except Exception:
|
||||
logger.exception("Failed to unregister agent coordinator tools")
|
||||
# Unregister workflow agent tools from the core AI tool registry
|
||||
try:
|
||||
from app.ai.tool_registry import get_tool_registry
|
||||
get_tool_registry().unregister_plugin(self.manifest.name)
|
||||
logger.info("Unregistered AI agent tools for plugin '%s'", self.manifest.name)
|
||||
except Exception:
|
||||
logger.exception("Failed to unregister AI agent tools")
|
||||
# Unregister MiniApps
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry
|
||||
@@ -278,6 +429,9 @@ class AutomationPlugin(BasePlugin):
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
default_tenant_id = tenant.id if tenant else None
|
||||
if default_tenant_id is None:
|
||||
logger.warning("No tenant found — skipping plugin contributions registration")
|
||||
return
|
||||
|
||||
# Register agent definitions
|
||||
agent_names: list[str] = []
|
||||
@@ -328,29 +482,30 @@ class AutomationPlugin(BasePlugin):
|
||||
logger.exception("Failed to register contributed automation '%s' from plugin '%s'", prefixed_name, plugin_name)
|
||||
self._contributed_automations[plugin_name] = automation_names
|
||||
|
||||
# Register cron jobs
|
||||
# Register cron jobs (skip if no tenant exists yet)
|
||||
cron_job_names: list[str] = []
|
||||
for cron_def in manifest.cron_jobs:
|
||||
prefixed_name = f"{plugin_name}.{cron_def.name}"
|
||||
cron_job_names.append(prefixed_name)
|
||||
try:
|
||||
# Check if cron job already exists
|
||||
result = await db.execute(
|
||||
select(AutomationCronJob).where(AutomationCronJob.name == prefixed_name).limit(1)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is None:
|
||||
await CronJobService.create(db, default_tenant_id, {
|
||||
"name": prefixed_name,
|
||||
"cron_expression": cron_def.cron_expression,
|
||||
"job_type": cron_def.job_type,
|
||||
"target_name": cron_def.target_name,
|
||||
"plugin_name": plugin_name,
|
||||
"is_active": True,
|
||||
})
|
||||
if default_tenant_id is not None:
|
||||
for cron_def in manifest.cron_jobs:
|
||||
prefixed_name = f"{plugin_name}.{cron_def.name}"
|
||||
cron_job_names.append(prefixed_name)
|
||||
try:
|
||||
# Check if cron job already exists
|
||||
result = await db.execute(
|
||||
select(AutomationCronJob).where(AutomationCronJob.name == prefixed_name).limit(1)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is None:
|
||||
await CronJobService.create(db, default_tenant_id, {
|
||||
"name": prefixed_name,
|
||||
"cron_expression": cron_def.cron_expression,
|
||||
"job_type": cron_def.job_type,
|
||||
"target_name": cron_def.target_name,
|
||||
"plugin_name": plugin_name,
|
||||
"is_active": True,
|
||||
})
|
||||
logger.info("Registered contributed cron job '%s' from plugin '%s'", prefixed_name, plugin_name)
|
||||
except Exception:
|
||||
logger.exception("Failed to register contributed cron job '%s' from plugin '%s'", prefixed_name, plugin_name)
|
||||
except Exception:
|
||||
logger.exception("Failed to register contributed cron job '%s' from plugin '%s'", prefixed_name, plugin_name)
|
||||
self._contributed_cron_jobs[plugin_name] = cron_job_names
|
||||
|
||||
# Register heartbeat configs
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Pre-built agent definitions for common CRM use cases."""
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Pre-built Contact-Enrichment-Agent.
|
||||
|
||||
Enriches contact data by searching for related information.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
|
||||
CONTACT_ENRICHMENT_SYSTEM_PROMPT = """You are a Contact Enrichment Agent for a CRM system.
|
||||
|
||||
Your task is to enrich contact profiles with additional information.
|
||||
|
||||
For each contact, you should:
|
||||
1. Search for related entities (companies, other contacts)
|
||||
2. Check audit history for recent interactions
|
||||
3. Find semantic matches in the database
|
||||
4. Suggest missing fields that could be filled
|
||||
5. Identify potential duplicates
|
||||
|
||||
Use the available tools to:
|
||||
- Search for related entities (search_related)
|
||||
- Get entity history (get_contact_history)
|
||||
- Call CRM API for data lookup (call_crm_api)
|
||||
|
||||
Output format:
|
||||
- Enrichment suggestions as structured data
|
||||
- Confidence score for each suggestion
|
||||
- Source reference for each piece of information
|
||||
|
||||
Do NOT modify contacts. You are advisory only.
|
||||
"""
|
||||
|
||||
def create_contact_enrichment_agent(
|
||||
tenant_id: uuid.UUID, user_id: uuid.UUID
|
||||
) -> AgentDefinition:
|
||||
return AgentDefinition(
|
||||
tenant_id=tenant_id,
|
||||
name="Contact-Enrichment-Agent",
|
||||
description="Reichert Kontaktdaten mit verwandten Informationen an",
|
||||
system_prompt=CONTACT_ENRICHMENT_SYSTEM_PROMPT,
|
||||
llm_model="openai/gpt-4o-mini",
|
||||
tool_ids=["search_related", "get_contact_history", "call_crm_api"],
|
||||
max_steps=8,
|
||||
max_duration_seconds=90,
|
||||
budget_limit_usd=0.30,
|
||||
mode="reactive",
|
||||
is_active=True,
|
||||
temperature=0.2,
|
||||
max_tokens=1500,
|
||||
trace_mode="standard",
|
||||
created_by=user_id,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Pre-built E-Mail-Triage-Agent.
|
||||
|
||||
Sorts and prioritizes incoming emails automatically.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
|
||||
EMAIL_TRIAGE_SYSTEM_PROMPT = """You are an E-Mail Triage Agent for a CRM system.
|
||||
|
||||
Your task is to sort and prioritize incoming emails for the user.
|
||||
|
||||
For each email, you should:
|
||||
1. Classify it as: urgent, important, normal, low_priority, or spam
|
||||
2. Extract key information: sender, subject, intent, action items
|
||||
3. Suggest a response category: reply_needed, forward, archive, delete
|
||||
4. Identify any contacts that should be linked
|
||||
|
||||
Use the available tools to:
|
||||
- Fetch emails for contacts (get_contact_mails)
|
||||
- Summarize email threads (summarize_mail_thread)
|
||||
- Call CRM API for contact/company data (call_crm_api)
|
||||
|
||||
Output format:
|
||||
- Provide a structured summary of each email
|
||||
- Include priority level and suggested action
|
||||
- Be concise but thorough
|
||||
|
||||
Do NOT send emails or make changes. You are advisory only.
|
||||
"""
|
||||
|
||||
def create_email_triage_agent(
|
||||
tenant_id: uuid.UUID, user_id: uuid.UUID
|
||||
) -> AgentDefinition:
|
||||
return AgentDefinition(
|
||||
tenant_id=tenant_id,
|
||||
name="E-Mail-Triage-Agent",
|
||||
description="Sortiert und priorisiert eingehende E-Mails automatisch",
|
||||
system_prompt=EMAIL_TRIAGE_SYSTEM_PROMPT,
|
||||
llm_model="openai/gpt-4o-mini",
|
||||
tool_ids=["get_contact_mails", "summarize_mail_thread", "call_crm_api"],
|
||||
max_steps=10,
|
||||
max_duration_seconds=120,
|
||||
budget_limit_usd=0.50,
|
||||
mode="reactive",
|
||||
is_active=True,
|
||||
temperature=0.3,
|
||||
max_tokens=2000,
|
||||
trace_mode="standard",
|
||||
created_by=user_id,
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Pre-built Follow-up-Agent.
|
||||
|
||||
Reminds about and creates follow-up tasks for contacts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
|
||||
FOLLOW_UP_SYSTEM_PROMPT = """You are a Follow-up Agent for a CRM system.
|
||||
|
||||
Your task is to identify and create follow-up tasks for contacts.
|
||||
|
||||
For each contact, you should:
|
||||
1. Check open tasks and calendar entries
|
||||
2. Review recent email communication
|
||||
3. Identify contacts that need follow-up (no response, overdue tasks, upcoming deadlines)
|
||||
4. Suggest follow-up actions (call, email, meeting, task)
|
||||
5. Create follow-up tasks when appropriate
|
||||
|
||||
Use the available tools to:
|
||||
- Get open tasks (get_open_tasks)
|
||||
- Get contact emails (get_contact_mails)
|
||||
- Call CRM API for task creation (call_crm_api)
|
||||
|
||||
Output format:
|
||||
- List of contacts needing follow-up with reason
|
||||
- Suggested action and timing for each
|
||||
- Priority level (urgent, this_week, this_month)
|
||||
|
||||
You may create tasks via call_crm_api. Always include a clear description and due date.
|
||||
"""
|
||||
|
||||
def create_follow_up_agent(
|
||||
tenant_id: uuid.UUID, user_id: uuid.UUID
|
||||
) -> AgentDefinition:
|
||||
return AgentDefinition(
|
||||
tenant_id=tenant_id,
|
||||
name="Follow-up-Agent",
|
||||
description="Erstellt und erinnert an Follow-up-Tasks für Kontakte",
|
||||
system_prompt=FOLLOW_UP_SYSTEM_PROMPT,
|
||||
llm_model="openai/gpt-4o-mini",
|
||||
tool_ids=["get_open_tasks", "get_contact_mails", "call_crm_api"],
|
||||
max_steps=8,
|
||||
max_duration_seconds=90,
|
||||
budget_limit_usd=0.30,
|
||||
mode="proactive",
|
||||
is_active=True,
|
||||
temperature=0.4,
|
||||
max_tokens=1500,
|
||||
trace_mode="standard",
|
||||
created_by=user_id,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Pre-built Report-Agent.
|
||||
|
||||
Generates reports from CRM data using search and API tools.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
|
||||
REPORT_SYSTEM_PROMPT = """You are a Report Agent for a CRM system.
|
||||
|
||||
Your task is to generate reports from CRM data.
|
||||
|
||||
You can:
|
||||
1. Search for contacts, companies, and activities using hybrid search
|
||||
2. Call CRM API for structured data (contacts, companies, tasks, calendar)
|
||||
3. Aggregate and summarize data into reports
|
||||
4. Format reports as markdown with tables and sections
|
||||
|
||||
Report types you can generate:
|
||||
- Contact activity summary (interactions, emails, tasks per contact)
|
||||
- Sales pipeline overview (contacts by status, recent changes)
|
||||
- Task completion report (open vs done, overdue, by assignee)
|
||||
- Communication summary (email volume, response times)
|
||||
- Custom reports based on user request
|
||||
|
||||
Use the available tools to gather data, then format a clear, structured report.
|
||||
Include relevant metrics, dates, and entity references.
|
||||
Be concise but comprehensive. Use markdown formatting.
|
||||
"""
|
||||
|
||||
def create_report_agent(
|
||||
tenant_id: uuid.UUID, user_id: uuid.UUID
|
||||
) -> AgentDefinition:
|
||||
return AgentDefinition(
|
||||
tenant_id=tenant_id,
|
||||
name="Report-Agent",
|
||||
description="Generiert Berichte aus CRM-Daten",
|
||||
system_prompt=REPORT_SYSTEM_PROMPT,
|
||||
llm_model="openai/gpt-4o-mini",
|
||||
tool_ids=["call_crm_api", "hybrid_search"],
|
||||
max_steps=12,
|
||||
max_duration_seconds=180,
|
||||
budget_limit_usd=0.50,
|
||||
mode="reactive",
|
||||
is_active=True,
|
||||
temperature=0.3,
|
||||
max_tokens=3000,
|
||||
trace_mode="standard",
|
||||
created_by=user_id,
|
||||
)
|
||||
@@ -190,7 +190,7 @@ async def list_miniapps(
|
||||
from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry
|
||||
registry = get_miniapp_registry()
|
||||
items = registry.list_apps()
|
||||
return {"items": items, "total": len(items)}
|
||||
return items
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -23,6 +23,13 @@ class AgentDefinitionCreate(BaseModel):
|
||||
max_executions_per_hour: int = Field(default=10, ge=1, le=1000)
|
||||
max_duration_seconds: int = Field(default=300, ge=1, le=86400)
|
||||
budget_limit_usd: float = Field(default=1.0, ge=0.0, le=10000.0)
|
||||
temperature: float = Field(default=0.3, ge=0.0, le=2.0)
|
||||
max_tokens: int = Field(default=1000, ge=1, le=100000)
|
||||
max_steps: int = Field(default=20, ge=1, le=100)
|
||||
trace_mode: str = Field(default="standard", pattern="^(standard|extended)$")
|
||||
skill_ids: list[str] = Field(default_factory=list)
|
||||
trigger_config: dict[str, Any] = Field(default_factory=dict)
|
||||
ai_use_case_metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentDefinitionUpdate(BaseModel):
|
||||
@@ -326,3 +333,52 @@ class SubtaskListResponse(BaseModel):
|
||||
|
||||
items: list[SubtaskRead]
|
||||
total: int
|
||||
|
||||
|
||||
# ─── Skill Definition Schemas (Phase F-SKILL) ───
|
||||
|
||||
|
||||
class SkillDefinitionCreate(BaseModel):
|
||||
"""Create a new skill definition."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str = Field(..., min_length=1)
|
||||
instructions: str = Field(..., min_length=1)
|
||||
allowed_tool_ids: list[str] = Field(default_factory=list)
|
||||
context_policy: dict[str, Any] | None = None
|
||||
category: str = Field(default="general", max_length=100)
|
||||
is_active: bool = Field(default=True)
|
||||
|
||||
|
||||
class SkillDefinitionUpdate(BaseModel):
|
||||
"""Update an existing skill definition (partial)."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
description: str | None = Field(None, min_length=1)
|
||||
instructions: str | None = Field(None, min_length=1)
|
||||
allowed_tool_ids: list[str] | None = None
|
||||
context_policy: dict[str, Any] | None = None
|
||||
category: str | None = Field(None, max_length=100)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class SkillDefinitionResponse(BaseModel):
|
||||
"""Skill definition response."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
instructions: str
|
||||
allowed_tool_ids: list[str] = []
|
||||
context_policy: dict[str, Any] | None = None
|
||||
category: str = "general"
|
||||
is_active: bool = True
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class SkillDefinitionListResponse(BaseModel):
|
||||
"""Paginated skill definition list."""
|
||||
|
||||
items: list[SkillDefinitionResponse]
|
||||
total: int
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user