T09: KI-Copilot API + Hybrid Workflow Engine + LLM client + event-triggered workflows
- KI-Copilot: NL query → proposed actions, execute with RBAC, history, audit logging - LLM client: mock mode (no API key) + OpenAI-compatible mode (AI_MODEL/AI_API_KEY) - Action mapper: NL intent → API calls (create/update/delete/search company/contact) - Workflow engine: step types (action/approval/notification/condition), JSONB steps - Workflow lifecycle: pending → in_progress → completed/rejected/cancelled - Event-triggered workflows: event bus → auto-start instances - Code-engine workflows: onboarding on user.created event - Approval timeout: auto-reject after configured hours - 5 new tenant-scoped tables with RLS: ai_conversations, ai_messages, workflows, workflow_instances, workflow_step_history - Migration 0004: all tables + RLS policies + tenant_id + indexes - 238 tests pass (30 AC + 105 coverage + 103 existing), 84.12% T09 module coverage - MissingGreenlet fix: safe accessor helpers for async ORM attribute access
This commit is contained in:
@@ -0,0 +1,133 @@
|
|||||||
|
# T07a — Frontend Core SPA — Shell, Auth, Routing, i18n, UI Library, Accessibility
|
||||||
|
|
||||||
|
## Project Root
|
||||||
|
/a0/usr/workdir/dev-projects/leocrm
|
||||||
|
|
||||||
|
## Frontend Directory
|
||||||
|
/a0/usr/workdir/dev-projects/leocrm/frontend/
|
||||||
|
|
||||||
|
## Tech Stack (CONFIRMED from architecture.md)
|
||||||
|
- React 18 + Vite + TypeScript
|
||||||
|
- React Router v6
|
||||||
|
- TanStack Query (React Query v5) for server state
|
||||||
|
- Zustand for client state
|
||||||
|
- react-i18next for i18n (de/en)
|
||||||
|
- React Hook Form + Zod for forms
|
||||||
|
- Tailwind CSS with design tokens from prototype
|
||||||
|
- Vitest for testing
|
||||||
|
|
||||||
|
## Backend API (already running, T01-T03 complete)
|
||||||
|
- Base URL: http://localhost:8000
|
||||||
|
- Auth: session cookie (leocrm_session), SameSite=strict
|
||||||
|
- CORS: http://localhost:5173 (Vite dev) allowed
|
||||||
|
- Endpoints available: /api/v1/auth/login, /api/v1/auth/logout, /api/v1/auth/me, /api/v1/auth/password-reset/request, /api/v1/auth/password-reset/confirm, /api/v1/users, /api/v1/companies, /api/v1/contacts, /api/v1/notifications, /api/v1/plugins, /health
|
||||||
|
|
||||||
|
## Requirements (23)
|
||||||
|
F-AUTH-01, F-AUTH-02, F-AUTH-03, F-AUTH-05, F-AUTH-07, F-CORE-06, F-CORE-07, F-CORE-08, F-CORE-09, F-CORE-13, F-A11Y-01, F-A11Y-02, F-A11Y-03, F-INT-01, F-NAV-01, F-UI-01 through F-UI-06, F-UI-08, F-TEST-01
|
||||||
|
|
||||||
|
## Acceptance Criteria (27)
|
||||||
|
1. Login page renders with email+password form
|
||||||
|
2. Login with valid credentials → redirect to Dashboard
|
||||||
|
3. Login with invalid credentials → error toast shown
|
||||||
|
4. Password reset request page renders and submits
|
||||||
|
5. Password reset confirm page renders with token validation
|
||||||
|
6. App shell renders with sidebar (plugin menu), topbar (tenant switcher, search, notifications, user menu), content area
|
||||||
|
7. Router navigates between routes without page reload (SPA)
|
||||||
|
8. Protected routes redirect to /login when not authenticated
|
||||||
|
9. Tenant switcher shows current tenant and allows switching
|
||||||
|
10. API client sends session cookie automatically via axios interceptor
|
||||||
|
11. API client handles 401 → redirect to login
|
||||||
|
12. API client handles 422 → display validation errors
|
||||||
|
13. i18n: German locale loads by default
|
||||||
|
14. i18n: English locale switchable via settings
|
||||||
|
15. UI Library: Button renders with variants (primary, secondary, danger, ghost)
|
||||||
|
16. UI Library: Input renders with label, error, helper text
|
||||||
|
17. UI Library: Modal opens/closes with backdrop click and ESC
|
||||||
|
18. UI Library: Toast notifications appear and auto-dismiss
|
||||||
|
19. UI Library: Table renders with sortable headers
|
||||||
|
20. UI Library: Card, Badge, Avatar, Pagination, EmptyState, Skeleton, ConfirmDialog render correctly
|
||||||
|
21. Accessibility: All interactive elements have ARIA labels
|
||||||
|
22. Accessibility: Keyboard navigation works (Tab, Enter, Escape, Arrow keys)
|
||||||
|
23. Accessibility: 44px minimum touch targets on mobile
|
||||||
|
24. Accessibility: prefers-reduced-motion respected
|
||||||
|
25. Vite dev server starts without errors
|
||||||
|
26. Production build (npm run build) succeeds with 0 errors
|
||||||
|
27. TypeScript: tsc --noEmit passes with 0 errors
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
- frontend/package.json (React 18, Vite, TanStack Query, Zustand, react-i18next, React Hook Form, Zod, Tailwind CSS, Vitest, axios)
|
||||||
|
- frontend/vite.config.ts
|
||||||
|
- frontend/tsconfig.json, tsconfig.node.json
|
||||||
|
- frontend/tailwind.config.js, postcss.config.js
|
||||||
|
- frontend/index.html
|
||||||
|
- frontend/src/main.tsx — React entry point with providers
|
||||||
|
- frontend/src/App.tsx — Router + providers setup
|
||||||
|
- frontend/src/api/client.ts — axios instance with interceptors (cookie, 401, 422)
|
||||||
|
- frontend/src/api/hooks.ts — TanStack Query hooks for auth, users, companies, contacts, notifications
|
||||||
|
- frontend/src/store/authStore.ts — Zustand auth store
|
||||||
|
- frontend/src/store/uiStore.ts — Zustand UI store (theme, sidebar, locale)
|
||||||
|
- frontend/src/i18n/index.ts — react-i18next setup
|
||||||
|
- frontend/src/i18n/locales/de.json, en.json
|
||||||
|
- frontend/src/components/ui/Button.tsx
|
||||||
|
- frontend/src/components/ui/Input.tsx
|
||||||
|
- frontend/src/components/ui/Select.tsx
|
||||||
|
- frontend/src/components/ui/Modal.tsx
|
||||||
|
- frontend/src/components/ui/Toast.tsx (ToastContainer + useToast)
|
||||||
|
- frontend/src/components/ui/Table.tsx
|
||||||
|
- frontend/src/components/ui/Card.tsx
|
||||||
|
- frontend/src/components/ui/Badge.tsx
|
||||||
|
- frontend/src/components/ui/Avatar.tsx
|
||||||
|
- frontend/src/components/ui/Pagination.tsx
|
||||||
|
- frontend/src/components/ui/EmptyState.tsx
|
||||||
|
- frontend/src/components/ui/Skeleton.tsx
|
||||||
|
- frontend/src/components/ui/ConfirmDialog.tsx
|
||||||
|
- frontend/src/components/layout/AppShell.tsx — Sidebar + TopBar + ContentArea
|
||||||
|
- frontend/src/components/layout/Sidebar.tsx — Plugin menu, navigation
|
||||||
|
- frontend/src/components/layout/TopBar.tsx — Tenant switcher, search, notifications, user menu
|
||||||
|
- frontend/src/pages/Login.tsx
|
||||||
|
- frontend/src/pages/PasswordResetRequest.tsx
|
||||||
|
- frontend/src/pages/PasswordResetConfirm.tsx
|
||||||
|
- frontend/src/pages/Dashboard.tsx (placeholder)
|
||||||
|
- frontend/src/pages/Settings.tsx (locale switch, theme)
|
||||||
|
- frontend/src/routes/index.tsx — Route definitions with guards
|
||||||
|
- frontend/src/routes/ProtectedRoute.tsx — Auth guard
|
||||||
|
- frontend/src/hooks/useAuth.ts
|
||||||
|
- frontend/src/hooks/useTenant.ts
|
||||||
|
- frontend/src/index.css — Tailwind directives + design tokens
|
||||||
|
- frontend/src/__tests__/shell/ (AppShell, Router, Sidebar, TopBar tests)
|
||||||
|
- frontend/src/__tests__/auth/ (Login, PasswordReset tests)
|
||||||
|
- frontend/src/__tests__/ui/ (Button, Input, Modal, Toast, Table, etc. tests)
|
||||||
|
- frontend/vitest.config.ts (or merge into vite.config.ts)
|
||||||
|
- frontend/src/test/setup.ts — Vitest setup (jsdom, i18n, mocks)
|
||||||
|
|
||||||
|
## Design Tokens (from prototype)
|
||||||
|
- Reference: https://webspace.media-on.de/leocrm-prototype-x7k2p9/
|
||||||
|
- Primary color, secondary, accent, danger, warning, success
|
||||||
|
- Spacing scale, border radius, shadows
|
||||||
|
- Typography: font families, sizes, weights
|
||||||
|
- Define as CSS custom properties in index.css + Tailwind config
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
- Use TypeScript strict mode
|
||||||
|
- All components must have ARIA labels for interactive elements
|
||||||
|
- 44px minimum touch targets on mobile (Tailwind min-h-[44px] min-w-[44px])
|
||||||
|
- prefers-reduced-motion: use Tailwind motion-safe/motion-reduce variants
|
||||||
|
- Session cookie: axios with withCredentials: true
|
||||||
|
- 401 handler: redirect to /login, clear auth store
|
||||||
|
- 422 handler: extract validation errors, display in form
|
||||||
|
- i18n: German default, English switchable
|
||||||
|
- No Lorem Ipsum — use real German/English content
|
||||||
|
- Test with Vitest + jsdom + @testing-library/react
|
||||||
|
- Coverage target: 80%
|
||||||
|
|
||||||
|
## Test Commands
|
||||||
|
cd /a0/usr/workdir/dev-projects/leocrm/frontend && npx vitest run src/__tests__/ --reporter=verbose
|
||||||
|
cd /a0/usr/workdir/dev-projects/leocrm/frontend && npm run build
|
||||||
|
cd /a0/usr/workdir/dev-projects/leocrm/frontend && npx tsc --noEmit
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
1. Complete frontend/ directory with all files listed above
|
||||||
|
2. All 27 ACs covered by tests
|
||||||
|
3. npm run build succeeds with 0 errors
|
||||||
|
4. tsc --noEmit passes with 0 errors
|
||||||
|
5. Report: test results, AC coverage, files, bugs
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# T09 — KI-Copilot API + Hybrid Workflow Engine Backend
|
||||||
|
|
||||||
|
## Project Root
|
||||||
|
/a0/usr/workdir/dev-projects/leocrm
|
||||||
|
|
||||||
|
## Backend Directory
|
||||||
|
/a0/usr/workdir/dev-projects/leocrm/app/
|
||||||
|
|
||||||
|
## Tech Stack (existing)
|
||||||
|
- FastAPI + SQLAlchemy 2.0 + asyncpg + Pydantic v2 + ARQ
|
||||||
|
- PostgreSQL 18 on localhost:5432 (user/db: leocrm/leocrm + leocrm_test)
|
||||||
|
- Redis on localhost:6379
|
||||||
|
- venv at /opt/venv (already activated)
|
||||||
|
- T01-T03 complete (103 tests pass, commit 7a5a48f)
|
||||||
|
|
||||||
|
## Requirements (5)
|
||||||
|
F-AI-01, F-WF-01, F-CORE-01, F-CORE-06, F-TEST-01
|
||||||
|
|
||||||
|
## Acceptance Criteria (22)
|
||||||
|
### KI-Copilot (7 ACs)
|
||||||
|
1. POST /api/v1/ai/copilot/query mit NL input → 200 + proposed_actions array
|
||||||
|
2. POST /api/v1/ai/copilot/execute mit proposed action → 200 + API result (RBAC enforced)
|
||||||
|
3. POST /api/v1/ai/copilot/execute als viewer mit delete action → 403 (RBAC blocks)
|
||||||
|
4. GET /api/v1/ai/copilot/history → 200 + paginated conversation history
|
||||||
|
5. Copilot action logged in audit_log with entity_type=ai_copilot
|
||||||
|
6. Copilot respects tenant isolation: cross-tenant → 404
|
||||||
|
7. Copilot respects field-level permissions: hidden fields not in response
|
||||||
|
|
||||||
|
### Workflow Engine (15 ACs)
|
||||||
|
8. POST /api/v1/workflows mit valid steps JSONB → 201 + workflow definition
|
||||||
|
9. GET /api/v1/workflows → 200 + paginated list
|
||||||
|
10. GET /api/v1/workflows/{id} → 200 + workflow detail with steps
|
||||||
|
11. PATCH /api/v1/workflows/{id} → 200, updated
|
||||||
|
12. DELETE /api/v1/workflows/{id} → 204
|
||||||
|
13. POST /api/v1/workflows/{id}/instances → 201, instance created with status=pending
|
||||||
|
14. GET /api/v1/workflows/instances?status=in_progress → 200 + filtered list
|
||||||
|
15. GET /api/v1/workflows/instances/{id} → 200 + current_step_index + history
|
||||||
|
16. POST /api/v1/workflows/instances/{id}/advance (approve) → 200, step advanced
|
||||||
|
17. POST /api/v1/workflows/instances/{id}/advance (reject) → 200, status=rejected, initiator notified
|
||||||
|
18. POST /api/v1/workflows/instances/{id}/cancel → 200, status=cancelled
|
||||||
|
19. Event-triggered workflow: publish event → workflow instance auto-starts
|
||||||
|
20. workflow_step_history entry created on every step transition
|
||||||
|
21. Code-engine workflow: onboarding workflow runs on user creation
|
||||||
|
22. Approval step timeout → auto-reject after configured hours (tested with mock timer)
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
### KI-Copilot
|
||||||
|
- app/models/ai_conversation.py — AIConversation, AIMessage models (tenant-scoped)
|
||||||
|
- app/schemas/ai_copilot.py — CopilotQueryRequest, CopilotAction, CopilotExecuteRequest, CopilotHistoryResponse
|
||||||
|
- app/services/ai_copilot_service.py — NL→API translation, LLM client, RBAC enforcement, audit logging
|
||||||
|
- app/routes/ai_copilot.py — POST /query, POST /execute, GET /history
|
||||||
|
- app/ai/__init__.py
|
||||||
|
- app/ai/llm_client.py — Configurable LLM client (AI_MODEL, AI_API_KEY env vars)
|
||||||
|
- app/ai/action_mapper.py — Maps NL intents to API calls
|
||||||
|
|
||||||
|
### Workflow Engine
|
||||||
|
- app/models/workflow.py — Workflow, WorkflowInstance, WorkflowStepHistory models (tenant-scoped)
|
||||||
|
- app/schemas/workflow.py — WorkflowCreate, WorkflowResponse, InstanceCreate, InstanceResponse, AdvanceRequest
|
||||||
|
- app/services/workflow_service.py — CRUD workflows, instance lifecycle (start/advance/approve/reject/cancel)
|
||||||
|
- app/routes/workflows.py — Workflow CRUD + instance endpoints
|
||||||
|
- app/workflows/__init__.py
|
||||||
|
- app/workflows/code/__init__.py — Code-engine workflows
|
||||||
|
- app/workflows/code/onboarding.py — Onboarding workflow (runs on user creation)
|
||||||
|
- app/workflows/engine.py — Workflow execution engine (step processing, conditions, approvals)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- tests/test_ai_copilot.py — 7 AC tests + edge cases
|
||||||
|
- tests/test_workflows.py — 15 AC tests + edge cases
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
- alembic/versions/0004_ai_workflows.py — ai_conversations, ai_messages, workflows, workflow_instances, workflow_step_history tables (all tenant-scoped with RLS)
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
- app/main.py — Register ai_copilot + workflows routers
|
||||||
|
- app/models/__init__.py — Add new model imports
|
||||||
|
- app/routes/__init__.py — Add new router imports
|
||||||
|
- app/schemas/__init__.py — Add new schema imports
|
||||||
|
- app/services/__init__.py — Add new service imports
|
||||||
|
- tests/conftest.py — Add new tables to TRUNCATE list
|
||||||
|
- app/core/event_bus.py — Add workflow event trigger integration (if not already present)
|
||||||
|
|
||||||
|
## LLM Client Design
|
||||||
|
- Read AI_MODEL and AI_API_KEY from environment
|
||||||
|
- If not set, use mock/stub mode (returns predefined actions for tests)
|
||||||
|
- Support OpenAI-compatible API (default)
|
||||||
|
- NL → proposed API calls: method, path, body, description
|
||||||
|
- Never execute directly — always return proposed actions for user confirmation
|
||||||
|
|
||||||
|
## Workflow Engine Design
|
||||||
|
- Step types: action, approval, notification, condition
|
||||||
|
- Workflow definition: JSONB steps array
|
||||||
|
- Instance lifecycle: pending → in_progress → completed/rejected/cancelled
|
||||||
|
- Event bus integration: subscribe to events, auto-start workflows with matching trigger
|
||||||
|
- Code-engine: hardcoded workflows in app/workflows/code/ (onboarding on user.created event)
|
||||||
|
- Approval timeout: configurable hours, auto-reject via ARQ scheduled job or mock timer in tests
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
- All POST routes MUST have status_code=201 (except execute/advance/cancel which are actions → 200)
|
||||||
|
- Use set_config() for tenant context, NOT SET LOCAL
|
||||||
|
- Use .com emails in tests, NOT .test
|
||||||
|
- All new tables MUST have tenant_id column + RLS policies
|
||||||
|
- Update tests/conftest.py TRUNCATE list with new tables
|
||||||
|
- Create Alembic migration 0004 for all new tables
|
||||||
|
- Copilot MUST enforce RBAC (same middleware, same permissions)
|
||||||
|
- Copilot MUST respect tenant isolation and field-level permissions
|
||||||
|
- Audit log entity_type=ai_copilot for all copilot actions
|
||||||
|
- Workflow mutations MUST be logged in workflow_step_history
|
||||||
|
- Idempotent where applicable
|
||||||
|
|
||||||
|
## Test Commands
|
||||||
|
cd /a0/usr/workdir/dev-projects/leocrm && python -m pytest tests/test_ai_copilot.py tests/test_workflows.py -v --tb=short
|
||||||
|
cd /a0/usr/workdir/dev-projects/leocrm && python -m pytest tests/ -v --tb=short (full suite regression)
|
||||||
|
|
||||||
|
## Coverage Target
|
||||||
|
80% for new modules
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
1. All files listed above
|
||||||
|
2. Alembic migration 0004
|
||||||
|
3. tests/test_ai_copilot.py + tests/test_workflows.py covering all 22 ACs
|
||||||
|
4. Report: test results, AC coverage, files, bugs
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Current Status — LeoCRM
|
||||||
|
|
||||||
|
**Phase**: 3 — Implementation
|
||||||
|
**Status**: T03 COMPLETE — 103/103 tests pass, 85.92% plugin coverage
|
||||||
|
**Commit**: 7a5a48f (pushed to Forgejo)
|
||||||
|
**Date**: 2026-06-29 01:20 CEST
|
||||||
|
|
||||||
|
## Completed Tasks
|
||||||
|
- T01 ✅ Core Infrastructure + Auth (29 tests)
|
||||||
|
- T02 ✅ Companies + Contacts + Import/Export (27 tests)
|
||||||
|
- T03 ✅ Plugin System Framework (47 tests)
|
||||||
|
|
||||||
|
## Next Action
|
||||||
|
- Delegate T07a (Frontend SPA Shell) + T09 (KI-Copilot API) in parallel
|
||||||
|
- Awaiting user approval
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Next Steps — LeoCRM
|
||||||
|
|
||||||
|
## Current: T03 COMPLETE ✅ (commit 7a5a48f, pushed)
|
||||||
|
|
||||||
|
## Phase 3 Implementation Progress
|
||||||
|
- T01 ✅ Core Infrastructure + Multi-Tenant + Auth (commit 7a7daf8)
|
||||||
|
- T02 ✅ Company + Contact + Import/Export (commit 6bf0746)
|
||||||
|
- T03 ✅ Plugin System Framework (commit 7a5a48f)
|
||||||
|
|
||||||
|
## Next: T07a ∥ T09 (Parallel)
|
||||||
|
### T07a — Frontend SPA Shell
|
||||||
|
- Deps: T01 ✅
|
||||||
|
- Reqs: 23 features, 27 ACs
|
||||||
|
- Vue 3 + Vite + Pinia + Vue Router + TailwindCSS
|
||||||
|
- Auth flow, layout, navigation, dashboard, settings
|
||||||
|
|
||||||
|
### T09 — KI-Copilot API + Hybrid Workflow Engine Backend
|
||||||
|
- Deps: T01 ✅, T02 ✅
|
||||||
|
- Reqs: 5 features, 22 ACs
|
||||||
|
- LLM integration, workflow engine, task automation
|
||||||
|
|
||||||
|
## After T07a + T09: T07b (Frontend Feature Pages)
|
||||||
|
## After T07b: T10 (Monitoring, Performance, Documentation)
|
||||||
|
|
||||||
|
## v1 Execution Order
|
||||||
|
T01 ✅ → T02 ✅ → T03 ✅ → (T07a ∥ T09) → T07b → T10
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
|
||||||
|
## T03 — Plugin System Framework — COMPLETE ✅
|
||||||
|
**Date**: 2026-06-29 01:20
|
||||||
|
**Commit**: 7a5a48f (pushed to Forgejo)
|
||||||
|
**Tests**: 47/47 T03 tests pass, 103/103 full suite pass
|
||||||
|
**Coverage**: 85.92% for plugin modules (target: 85% ✅)
|
||||||
|
**Migration**: 0003_plugin_system.py applied (plugins + plugin_migrations tables)
|
||||||
|
|
||||||
|
### Files Created (12 new)
|
||||||
|
- app/plugins/__init__.py, manifest.py, base.py, registry.py, migration_runner.py
|
||||||
|
- app/plugins/builtins/__init__.py, test_sample.py, migrations/0001_test_plugin.sql, migrations/0001_bad_migration.sql
|
||||||
|
- app/models/plugin.py, app/schemas/plugin.py, app/services/plugin_service.py, app/routes/plugins.py
|
||||||
|
- alembic/versions/0003_plugin_system.py
|
||||||
|
- tests/test_plugins.py (47 tests, 14 ACs + 33 unit tests)
|
||||||
|
|
||||||
|
### Files Modified (8)
|
||||||
|
- app/main.py (plugins router + registry init in lifespan)
|
||||||
|
- app/models/__init__.py, app/routes/__init__.py, app/schemas/__init__.py, app/services/__init__.py
|
||||||
|
- tests/conftest.py (plugin tables in TRUNCATE list)
|
||||||
|
|
||||||
|
### Bugs Fixed by Subagent
|
||||||
|
1. Unterminated f-string in registry.py
|
||||||
|
2. Migration runner DB connection visibility (now uses session's own connection)
|
||||||
|
3. Route unregistration by path match (FastAPI wraps routes differently)
|
||||||
|
4. Dollar-quote SQL splitting (flush after closing $$)
|
||||||
|
5. AC11 assertion type (dict vs string for HTTPException detail)
|
||||||
|
|
||||||
|
### Verification (Orchestrator Independent)
|
||||||
|
- pytest tests/test_plugins.py -v: 47/47 PASS
|
||||||
|
- pytest tests/ -v: 103/103 PASS (zero regressions)
|
||||||
|
- Coverage: 85.92% (manifest 100%, base 88%, registry 88%, migration_runner 79%)
|
||||||
|
- Migration 0003 applied via alembic upgrade head
|
||||||
|
- No forbidden patterns found
|
||||||
|
- Pushed to Forgejo: 6bf0746..7a5a48f
|
||||||
|
|
||||||
|
### Next: T07a (Frontend SPA Shell) ∥ T09 (KI-Copilot API) — parallel delegation
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""T09: ai_conversations, ai_messages, workflows, workflow_instances, workflow_step_history tables.
|
||||||
|
|
||||||
|
Revision ID: 0004_ai_workflows
|
||||||
|
Revises: 0003_plugin_system
|
||||||
|
Create Date: 2026-06-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0004_ai_workflows"
|
||||||
|
down_revision: Union[str, None] = "0003_plugin_system"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# --- ai_conversations table (tenant-scoped) ---
|
||||||
|
op.create_table(
|
||||||
|
"ai_conversations",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False, server_default="Untitled"),
|
||||||
|
sa.Column("context", postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
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()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_ai_conversations_tenant_id", "ai_conversations", ["tenant_id"])
|
||||||
|
op.create_index("ix_ai_conversations_tenant_user", "ai_conversations", ["tenant_id", "user_id"])
|
||||||
|
|
||||||
|
# --- ai_messages table (tenant-scoped) ---
|
||||||
|
op.create_table(
|
||||||
|
"ai_messages",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("conversation_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("ai_conversations.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("role", sa.String(20), nullable=False),
|
||||||
|
sa.Column("content", sa.Text, nullable=False),
|
||||||
|
sa.Column("proposed_actions", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("executed_action", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("execution_result", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("message_index", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
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()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_ai_messages_tenant_id", "ai_messages", ["tenant_id"])
|
||||||
|
op.create_index("ix_ai_messages_tenant_conversation", "ai_messages", ["tenant_id", "conversation_id"])
|
||||||
|
op.create_index("ix_ai_messages_conversation_id", "ai_messages", ["conversation_id"])
|
||||||
|
|
||||||
|
# --- workflows table (tenant-scoped) ---
|
||||||
|
op.create_table(
|
||||||
|
"workflows",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("description", sa.Text, nullable=True),
|
||||||
|
sa.Column("trigger_event", sa.String(100), nullable=True),
|
||||||
|
sa.Column("steps", postgresql.JSONB, nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), 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()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_workflows_tenant_id", "workflows", ["tenant_id"])
|
||||||
|
op.create_index("ix_workflows_tenant_active", "workflows", ["tenant_id", "is_active"])
|
||||||
|
op.create_index("ix_workflows_tenant_trigger", "workflows", ["tenant_id", "trigger_event"])
|
||||||
|
|
||||||
|
# --- workflow_instances table (tenant-scoped) ---
|
||||||
|
op.create_table(
|
||||||
|
"workflow_instances",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("workflow_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("status", sa.String(30), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("current_step_index", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("context", postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
sa.Column("initiated_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("timeout_hours", sa.Integer, nullable=True),
|
||||||
|
sa.Column("timeout_at", sa.DateTime(timezone=True), 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()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_wf_instances_tenant_id", "workflow_instances", ["tenant_id"])
|
||||||
|
op.create_index("ix_wf_instances_tenant_status", "workflow_instances", ["tenant_id", "status"])
|
||||||
|
op.create_index("ix_wf_instances_tenant_workflow", "workflow_instances", ["tenant_id", "workflow_id"])
|
||||||
|
op.create_index("ix_wf_instances_workflow_id", "workflow_instances", ["workflow_id"])
|
||||||
|
|
||||||
|
# --- workflow_step_history table (tenant-scoped) ---
|
||||||
|
op.create_table(
|
||||||
|
"workflow_step_history",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("instance_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("workflow_instances.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("step_index", sa.Integer, nullable=False),
|
||||||
|
sa.Column("step_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("action", sa.String(50), nullable=False),
|
||||||
|
sa.Column("actor_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("details", postgresql.JSONB, 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()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_wf_step_history_tenant_id", "workflow_step_history", ["tenant_id"])
|
||||||
|
op.create_index("ix_wf_step_history_tenant_instance", "workflow_step_history", ["tenant_id", "instance_id"])
|
||||||
|
op.create_index("ix_wf_step_history_instance_id", "workflow_step_history", ["instance_id"])
|
||||||
|
|
||||||
|
# --- RLS Policies ---
|
||||||
|
for table in ["ai_conversations", "ai_messages", "workflows", "workflow_instances", "workflow_step_history"]:
|
||||||
|
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;")
|
||||||
|
op.execute(
|
||||||
|
f"CREATE POLICY {table}_tenant_isolation ON {table} "
|
||||||
|
f"USING (tenant_id::text = current_setting('app.current_tenant_id', true));"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in ["workflow_step_history", "workflow_instances", "workflows", "ai_messages", "ai_conversations"]:
|
||||||
|
op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table};")
|
||||||
|
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;")
|
||||||
|
op.drop_table(table)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""AI Copilot modules — LLM client and action mapper."""
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Action mapper — maps natural language intents to proposed API calls.
|
||||||
|
|
||||||
|
Used by the mock LLM client for test mode and as a fallback.
|
||||||
|
Supports keyword-based intent detection for common CRM operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# Precompiled patterns for intent detection
|
||||||
|
_PATTERNS = {
|
||||||
|
'create_company': re.compile(r'\b(create|add|new)\b.*\b(company|firm|organization|organisation)\b', re.IGNORECASE),
|
||||||
|
'delete_company': re.compile(r'\b(delete|remove)\b.*\b(company|firm)\b', re.IGNORECASE),
|
||||||
|
'update_company': re.compile(r'\b(update|edit|modify|change)\b.*\b(company|firm)\b', re.IGNORECASE),
|
||||||
|
'list_company': re.compile(r'\b(list|show|find|search|get|display)\b.*\b(compan|firm)\b', re.IGNORECASE),
|
||||||
|
'list_company2': re.compile(r'\bcompan.*\b(list|all)\b', re.IGNORECASE),
|
||||||
|
'create_contact': re.compile(r'\b(create|add|new)\b.*\b(contact|person)\b', re.IGNORECASE),
|
||||||
|
'list_contact': re.compile(r'\b(list|show|find|search|get|display)\b.*\b(contact|person)\b', re.IGNORECASE),
|
||||||
|
'list_workflow': re.compile(r'\b(list|show|get|display)\b.*\b(workflow)\b', re.IGNORECASE),
|
||||||
|
'create_workflow': re.compile(r'\b(create|new|add)\b.*\b(workflow)\b', re.IGNORECASE),
|
||||||
|
'help': re.compile(r'\b(help|what can you do|assist)\b', re.IGNORECASE),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Name extraction patterns - using single-quoted strings to avoid escaping issues
|
||||||
|
_NAME_PATTERNS = [
|
||||||
|
re.compile(r"\b(?:named|called|for)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
|
re.compile(r"\bcompany\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
|
re.compile(r"\bcontact\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
|
re.compile(r"\bworkflow\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
|
re.compile(r"\bfirm\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
|
re.compile(r"\bperson\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Field extraction patterns
|
||||||
|
_INDUSTRY_PAT = re.compile(r"industry\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
|
||||||
|
_NAME_UPDATE_PAT = re.compile(r"(?:name|rename)\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
|
||||||
|
_PHONE_PAT = re.compile(r"phone\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
|
||||||
|
_EMAIL_PAT = re.compile(r"email\s+(?:to|:)?\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
|
||||||
|
_SEARCH_PAT = re.compile(r"\b(?:named|called|matching|with name)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""Map a natural language query to proposed API actions.
|
||||||
|
|
||||||
|
Uses keyword matching to detect intents. Returns a list of proposed
|
||||||
|
action dictionaries with method, path, body, description, and confidence.
|
||||||
|
"""
|
||||||
|
context = context or {}
|
||||||
|
q = query.lower().strip()
|
||||||
|
actions: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# --- Company intents ---
|
||||||
|
if _PATTERNS['create_company'].search(q):
|
||||||
|
name = _extract_name(query)
|
||||||
|
actions.append({
|
||||||
|
'method': 'POST',
|
||||||
|
'path': '/api/v1/companies',
|
||||||
|
'body': {'name': name or 'New Company'},
|
||||||
|
'description': f"Create a new company named '{name or 'New Company'}'",
|
||||||
|
'confidence': 0.9,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif _PATTERNS['delete_company'].search(q):
|
||||||
|
entity_id = context.get('company_id') or context.get('entity_id')
|
||||||
|
if entity_id:
|
||||||
|
actions.append({
|
||||||
|
'method': 'DELETE',
|
||||||
|
'path': f'/api/v1/companies/{entity_id}',
|
||||||
|
'body': None,
|
||||||
|
'description': f'Delete company {entity_id}',
|
||||||
|
'confidence': 0.9,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
actions.append({
|
||||||
|
'method': 'DELETE',
|
||||||
|
'path': '/api/v1/companies/{id}',
|
||||||
|
'body': None,
|
||||||
|
'description': 'Delete a company (requires company ID in context or selection)',
|
||||||
|
'confidence': 0.5,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif _PATTERNS['update_company'].search(q):
|
||||||
|
entity_id = context.get('company_id') or context.get('entity_id')
|
||||||
|
path = f'/api/v1/companies/{entity_id}' if entity_id else '/api/v1/companies/{id}'
|
||||||
|
actions.append({
|
||||||
|
'method': 'PATCH',
|
||||||
|
'path': path,
|
||||||
|
'body': _extract_update_fields(query),
|
||||||
|
'description': 'Update company information',
|
||||||
|
'confidence': 0.8,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif _PATTERNS['list_company'].search(q) or _PATTERNS['list_company2'].search(q):
|
||||||
|
search_term = _extract_search_term(query)
|
||||||
|
desc = 'List companies'
|
||||||
|
if search_term:
|
||||||
|
desc += f" matching '{search_term}'"
|
||||||
|
actions.append({
|
||||||
|
'method': 'GET',
|
||||||
|
'path': '/api/v1/companies',
|
||||||
|
'body': None,
|
||||||
|
'description': desc,
|
||||||
|
'confidence': 0.85,
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Contact intents ---
|
||||||
|
elif _PATTERNS['create_contact'].search(q):
|
||||||
|
name = _extract_name(query)
|
||||||
|
actions.append({
|
||||||
|
'method': 'POST',
|
||||||
|
'path': '/api/v1/contacts',
|
||||||
|
'body': {'name': name or 'New Contact'},
|
||||||
|
'description': f"Create a new contact named '{name or 'New Contact'}'",
|
||||||
|
'confidence': 0.9,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif _PATTERNS['list_contact'].search(q):
|
||||||
|
actions.append({
|
||||||
|
'method': 'GET',
|
||||||
|
'path': '/api/v1/contacts',
|
||||||
|
'body': None,
|
||||||
|
'description': 'List contacts',
|
||||||
|
'confidence': 0.85,
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Workflow intents ---
|
||||||
|
elif _PATTERNS['list_workflow'].search(q):
|
||||||
|
actions.append({
|
||||||
|
'method': 'GET',
|
||||||
|
'path': '/api/v1/workflows',
|
||||||
|
'body': None,
|
||||||
|
'description': 'List workflows',
|
||||||
|
'confidence': 0.85,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif _PATTERNS['create_workflow'].search(q):
|
||||||
|
name = _extract_name(query)
|
||||||
|
actions.append({
|
||||||
|
'method': 'POST',
|
||||||
|
'path': '/api/v1/workflows',
|
||||||
|
'body': {'name': name or 'New Workflow', 'steps': []},
|
||||||
|
'description': 'Create a new workflow',
|
||||||
|
'confidence': 0.8,
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Generic fallback ---
|
||||||
|
if not actions:
|
||||||
|
if _PATTERNS['help'].search(q):
|
||||||
|
actions.append({
|
||||||
|
'method': 'GET',
|
||||||
|
'path': '/api/v1/companies',
|
||||||
|
'body': None,
|
||||||
|
'description': 'Show available companies (demonstration action)',
|
||||||
|
'confidence': 0.3,
|
||||||
|
})
|
||||||
|
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_name(query: str) -> str | None:
|
||||||
|
"""Extract a name from the query, looking for 'named X', 'called X', 'for X'."""
|
||||||
|
for pat in _NAME_PATTERNS:
|
||||||
|
match = pat.search(query)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_search_term(query: str) -> str | None:
|
||||||
|
"""Extract a search term from the query."""
|
||||||
|
match = _SEARCH_PAT.search(query)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_update_fields(query: str) -> dict[str, Any]:
|
||||||
|
"""Extract fields to update from the query."""
|
||||||
|
fields: dict[str, Any] = {}
|
||||||
|
if re.search(r'\bindustry\b', query, re.IGNORECASE):
|
||||||
|
match = _INDUSTRY_PAT.search(query)
|
||||||
|
if match:
|
||||||
|
fields['industry'] = match.group(1).strip()
|
||||||
|
if re.search(r'\b(name|rename)\b', query, re.IGNORECASE):
|
||||||
|
match = _NAME_UPDATE_PAT.search(query)
|
||||||
|
if match:
|
||||||
|
fields['name'] = match.group(1).strip()
|
||||||
|
if re.search(r'\bphone\b', query, re.IGNORECASE):
|
||||||
|
match = _PHONE_PAT.search(query)
|
||||||
|
if match:
|
||||||
|
fields['phone'] = match.group(1).strip()
|
||||||
|
if re.search(r'\bemail\b', query, re.IGNORECASE):
|
||||||
|
match = _EMAIL_PAT.search(query)
|
||||||
|
if match:
|
||||||
|
fields['email'] = match.group(1).strip()
|
||||||
|
return fields or {'name': 'Updated Name'}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Configurable LLM client — supports OpenAI-compatible API or mock/stub mode.
|
||||||
|
|
||||||
|
Reads AI_MODEL and AI_API_KEY from environment. If not set, uses mock mode
|
||||||
|
which returns predefined actions based on keyword matching. This allows
|
||||||
|
tests to run without external API dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMResponse:
|
||||||
|
"""Structured LLM response containing proposed actions."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, proposed_actions: list[dict[str, Any]], confidence: float = 0.8):
|
||||||
|
self.message = message
|
||||||
|
self.proposed_actions = proposed_actions
|
||||||
|
self.confidence = confidence
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"message": self.message,
|
||||||
|
"proposed_actions": self.proposed_actions,
|
||||||
|
"confidence": self.confidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClient:
|
||||||
|
"""LLM client that translates natural language to proposed API actions.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
- If AI_MODEL and AI_API_KEY are set: calls OpenAI-compatible chat completions API
|
||||||
|
- Otherwise: mock/stub mode with keyword-based action mapping
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, model: str | None = None, api_key: str | None = None, api_base: str | None = None):
|
||||||
|
self.model = model or os.environ.get("AI_MODEL", "")
|
||||||
|
self.api_key = api_key or os.environ.get("AI_API_KEY", "")
|
||||||
|
self.api_base = api_base or os.environ.get("AI_API_BASE", "https://api.openai.com/v1")
|
||||||
|
self.is_mock = not bool(self.model and self.api_key)
|
||||||
|
|
||||||
|
async def generate(self, user_query: str, context: dict[str, Any] | None = None) -> LLMResponse:
|
||||||
|
"""Generate proposed actions from natural language query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_query: Natural language input from user
|
||||||
|
context: Optional context (e.g. current page, selected entity)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LLMResponse with message and proposed_actions list
|
||||||
|
"""
|
||||||
|
if self.is_mock:
|
||||||
|
return await self._mock_generate(user_query, context or {})
|
||||||
|
return await self._api_generate(user_query, context or {})
|
||||||
|
|
||||||
|
async def _mock_generate(self, query: str, context: dict[str, Any]) -> LLMResponse:
|
||||||
|
"""Mock/stub mode — keyword-based action mapping for tests."""
|
||||||
|
from app.ai.action_mapper import map_query_to_actions
|
||||||
|
|
||||||
|
actions = map_query_to_actions(query, context)
|
||||||
|
if actions:
|
||||||
|
return LLMResponse(
|
||||||
|
message=f"I found {len(actions)} possible action(s) based on your request.",
|
||||||
|
proposed_actions=actions,
|
||||||
|
confidence=0.85,
|
||||||
|
)
|
||||||
|
return LLMResponse(
|
||||||
|
message="I couldn't determine a specific action from your request. Could you be more specific?",
|
||||||
|
proposed_actions=[],
|
||||||
|
confidence=0.3,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _api_generate(self, query: str, context: dict[str, Any]) -> LLMResponse:
|
||||||
|
"""Call OpenAI-compatible chat completions API.
|
||||||
|
|
||||||
|
Sends a system prompt explaining the available API endpoints and asks
|
||||||
|
the LLM to propose actions in structured JSON format.
|
||||||
|
"""
|
||||||
|
system_prompt = self._build_system_prompt(context)
|
||||||
|
user_prompt = f"User request: {query}\n\nRespond with proposed actions as JSON."
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
body = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
"temperature": 0.3,
|
||||||
|
"max_tokens": 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self.api_base}/chat/completions",
|
||||||
|
headers=headers,
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
return self._parse_llm_response(content)
|
||||||
|
|
||||||
|
def _build_system_prompt(self, context: dict[str, Any]) -> str:
|
||||||
|
"""Build system prompt describing available API actions."""
|
||||||
|
available_apis = [
|
||||||
|
{"method": "GET", "path": "/api/v1/companies", "description": "List companies"},
|
||||||
|
{"method": "POST", "path": "/api/v1/companies", "description": "Create a company"},
|
||||||
|
{"method": "GET", "path": "/api/v1/companies/{id}", "description": "Get company details"},
|
||||||
|
{"method": "PATCH", "path": "/api/v1/companies/{id}", "description": "Update a company"},
|
||||||
|
{"method": "DELETE", "path": "/api/v1/companies/{id}", "description": "Delete a company"},
|
||||||
|
{"method": "GET", "path": "/api/v1/contacts", "description": "List contacts"},
|
||||||
|
{"method": "POST", "path": "/api/v1/contacts", "description": "Create a contact"},
|
||||||
|
{"method": "GET", "path": "/api/v1/workflows", "description": "List workflows"},
|
||||||
|
{"method": "POST", "path": "/api/v1/workflows", "description": "Create a workflow"},
|
||||||
|
]
|
||||||
|
context_str = json.dumps(context) if context else "{}"
|
||||||
|
return (
|
||||||
|
"You are an AI copilot for LeoCRM. Based on the user's natural language request, "
|
||||||
|
"propose one or more API actions. Always respond with a JSON object containing: "
|
||||||
|
'"message": a human-readable summary, '
|
||||||
|
'"proposed_actions": an array of {method, path, body, description, confidence}. '
|
||||||
|
f"Available API endpoints: {json.dumps(available_apis)}. "
|
||||||
|
f"Current context: {context_str}. "
|
||||||
|
"Never execute actions directly — only propose them for user confirmation."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_llm_response(self, content: str) -> LLMResponse:
|
||||||
|
"""Parse LLM JSON response into LLMResponse."""
|
||||||
|
try:
|
||||||
|
parsed = json.loads(content)
|
||||||
|
return LLMResponse(
|
||||||
|
message=parsed.get("message", "Here are the proposed actions."),
|
||||||
|
proposed_actions=parsed.get("proposed_actions", []),
|
||||||
|
confidence=parsed.get("confidence", 0.8),
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
logger.warning("Failed to parse LLM response as JSON: %s", content[:200])
|
||||||
|
return LLMResponse(
|
||||||
|
message=content,
|
||||||
|
proposed_actions=[],
|
||||||
|
confidence=0.3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global client instance
|
||||||
|
_client: LLMClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_client() -> LLMClient:
|
||||||
|
"""Get or create the global LLM client instance."""
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = LLMClient()
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def reset_llm_client() -> None:
|
||||||
|
"""Reset the global client (for testing)."""
|
||||||
|
global _client
|
||||||
|
_client = None
|
||||||
@@ -39,3 +39,13 @@ _event_bus = EventBus()
|
|||||||
def get_event_bus() -> EventBus:
|
def get_event_bus() -> EventBus:
|
||||||
"""Get the global event bus."""
|
"""Get the global event bus."""
|
||||||
return _event_bus
|
return _event_bus
|
||||||
|
|
||||||
|
|
||||||
|
def register_workflow_event_handlers() -> None:
|
||||||
|
"""Register workflow event handlers on the global event bus.
|
||||||
|
|
||||||
|
Subscribes to events that can trigger workflows (user.created, etc.).
|
||||||
|
Should be called during application startup.
|
||||||
|
"""
|
||||||
|
from app.workflows.engine import register_workflow_event_handlers as _register
|
||||||
|
_register()
|
||||||
|
|||||||
+3
-1
@@ -12,7 +12,7 @@ from app.core.middleware import CSRFMiddleware
|
|||||||
from app.core.db import close_engine, get_engine
|
from app.core.db import close_engine, get_engine
|
||||||
from app.core.service_container import get_container
|
from app.core.service_container import get_container
|
||||||
from app.plugins.registry import get_registry
|
from app.plugins.registry import get_registry
|
||||||
from app.routes import auth, users, roles, tenants, health, notifications, companies, contacts, import_export, plugins
|
from app.routes import auth, users, roles, tenants, health, notifications, companies, contacts, import_export, plugins, ai_copilot, workflows
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -57,6 +57,8 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(contacts.router)
|
app.include_router(contacts.router)
|
||||||
app.include_router(import_export.router)
|
app.include_router(import_export.router)
|
||||||
app.include_router(plugins.router)
|
app.include_router(plugins.router)
|
||||||
|
app.include_router(ai_copilot.router)
|
||||||
|
app.include_router(workflows.router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -18,3 +18,15 @@ __all__ = [
|
|||||||
"Contact", "CompanyContact",
|
"Contact", "CompanyContact",
|
||||||
"Plugin", "PluginMigration",
|
"Plugin", "PluginMigration",
|
||||||
]
|
]
|
||||||
|
from app.models.ai_conversation import AIConversation, AIMessage
|
||||||
|
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Tenant", "User", "UserTenant", "Role", "Session",
|
||||||
|
"AuditLog", "DeletionLog", "Notification",
|
||||||
|
"PasswordResetToken", "ApiToken", "Company",
|
||||||
|
"Contact", "CompanyContact",
|
||||||
|
"Plugin", "PluginMigration",
|
||||||
|
"AIConversation", "AIMessage",
|
||||||
|
"Workflow", "WorkflowInstance", "WorkflowStepHistory",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""AI Conversation and Message models — tenant-scoped with RLS."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, func, Index, Integer
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class AIConversation(Base, TenantMixin):
|
||||||
|
"""AI Copilot conversation thread — tenant-scoped."""
|
||||||
|
|
||||||
|
__tablename__ = "ai_conversations"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_ai_conversations_tenant_user", "tenant_id", "user_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), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False, default="Untitled")
|
||||||
|
context: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class AIMessage(Base, TenantMixin):
|
||||||
|
"""Individual messages within an AI conversation — user input, AI response, actions."""
|
||||||
|
|
||||||
|
__tablename__ = "ai_messages"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_ai_messages_tenant_conversation", "tenant_id", "conversation_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("ai_conversations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(String(20), nullable=False) # user, assistant, system
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
proposed_actions: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
executed_action: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
execution_result: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
message_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Workflow, WorkflowInstance, and WorkflowStepHistory models — tenant-scoped with RLS."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, func, Index, Integer, Boolean
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Workflow(Base, TenantMixin):
|
||||||
|
"""Workflow definition — a template of steps stored as JSONB, tenant-scoped."""
|
||||||
|
|
||||||
|
__tablename__ = "workflows"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_workflows_tenant_active", "tenant_id", "is_active"),
|
||||||
|
Index("ix_workflows_tenant_trigger", "tenant_id", "trigger_event"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
trigger_event: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
steps: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowInstance(Base, TenantMixin):
|
||||||
|
"""A running instance of a workflow — tracks current step, status, context."""
|
||||||
|
|
||||||
|
__tablename__ = "workflow_instances"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_wf_instances_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_wf_instances_tenant_workflow", "tenant_id", "workflow_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
workflow_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending")
|
||||||
|
# pending → in_progress → completed / rejected / cancelled
|
||||||
|
current_step_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
context: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||||
|
initiated_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStepHistory(Base, TenantMixin):
|
||||||
|
"""Immutable record of every step transition in a workflow instance."""
|
||||||
|
|
||||||
|
__tablename__ = "workflow_step_history"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_wf_step_history_tenant_instance", "tenant_id", "instance_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
instance_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("workflow_instances.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
step_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
step_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
action: Mapped[str] = mapped_column(String(50), nullable=False) # entered, approved, rejected, skipped, cancelled
|
||||||
|
actor_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
details: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Routes package."""
|
"""Routes package."""
|
||||||
|
|
||||||
from app.routes import auth, users, roles, tenants, health, notifications, companies, contacts, import_export, plugins
|
from app.routes import auth, users, roles, tenants, health, notifications, companies, contacts, import_export, plugins, ai_copilot, workflows
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""AI Copilot routes — query, execute, history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.auth import check_permission
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.deps import get_current_user
|
||||||
|
from app.schemas.ai_copilot import (
|
||||||
|
CopilotQueryRequest,
|
||||||
|
CopilotExecuteRequest,
|
||||||
|
)
|
||||||
|
from app.services import ai_copilot_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/ai/copilot", tags=["ai-copilot"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/query")
|
||||||
|
async def copilot_query(
|
||||||
|
body: CopilotQueryRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Process a natural language query and return proposed actions.
|
||||||
|
|
||||||
|
Returns proposed_actions array for user confirmation.
|
||||||
|
Does NOT execute any actions — user must call /execute to confirm.
|
||||||
|
"""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
|
||||||
|
result = await ai_copilot_service.process_query(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
query=body.query,
|
||||||
|
conversation_id=body.conversation_id,
|
||||||
|
context=body.context,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in result and result.get("status_code") == 404:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": result["error"], "code": "conversation_not_found"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/execute")
|
||||||
|
async def copilot_execute(
|
||||||
|
body: CopilotExecuteRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Execute a proposed action after user confirmation.
|
||||||
|
|
||||||
|
RBAC is enforced — the user must have permission for the action.
|
||||||
|
Returns 200 with execution result or 403 if RBAC blocks.
|
||||||
|
"""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
result = await ai_copilot_service.execute_action(
|
||||||
|
db, tenant_id, user_id, role,
|
||||||
|
conversation_id=body.conversation_id,
|
||||||
|
action=body.action.model_dump(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("status_code") == 404:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": result.get("error", "Not found"), "code": "not_found"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("status_code") == 403:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"detail": result.get("error", "Insufficient permissions"), "code": "forbidden"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def copilot_history(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get paginated conversation history for the current user."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
|
||||||
|
return await ai_copilot_service.get_history(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
page=page, page_size=page_size,
|
||||||
|
)
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""Workflow routes — CRUD, instance lifecycle, advance/cancel."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.auth import check_permission
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.deps import get_current_user
|
||||||
|
from app.schemas.workflow import WorkflowCreate, WorkflowUpdate, InstanceCreate, AdvanceRequest
|
||||||
|
from app.services import workflow_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/workflows", tags=["workflows"])
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Workflow CRUD ───
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_workflows(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
is_active: bool | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List workflows with pagination."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
return await workflow_service.list_workflows(
|
||||||
|
db, tenant_id,
|
||||||
|
page=page, page_size=page_size,
|
||||||
|
is_active=is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_workflow(
|
||||||
|
body: WorkflowCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a new workflow definition. Requires write permission."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "workflows", "create"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"detail": "Insufficient permissions", "code": "forbidden"},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = body.model_dump()
|
||||||
|
return await workflow_service.create_workflow(db, tenant_id, user_id, data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances")
|
||||||
|
async def list_instances(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
status: str | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List workflow instances with optional status filter."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
return await workflow_service.list_instances(
|
||||||
|
db, tenant_id,
|
||||||
|
page=page, page_size=page_size,
|
||||||
|
status_filter=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{workflow_id}")
|
||||||
|
async def get_workflow(
|
||||||
|
workflow_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get a single workflow by ID."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
result = await workflow_service.get_workflow(db, tenant_id, workflow_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": "Workflow not found", "code": "not_found"},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{workflow_id}")
|
||||||
|
async def update_workflow(
|
||||||
|
workflow_id: str,
|
||||||
|
body: WorkflowUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Update a workflow definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "workflows", "update"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"detail": "Insufficient permissions", "code": "forbidden"},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
result = await workflow_service.update_workflow(db, tenant_id, user_id, workflow_id, data)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": "Workflow not found", "code": "not_found"},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{workflow_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_workflow(
|
||||||
|
workflow_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Delete a workflow definition."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
role = current_user.get("role", "viewer")
|
||||||
|
|
||||||
|
if not check_permission(role, "workflows", "delete"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"detail": "Insufficient permissions", "code": "forbidden"},
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted = await workflow_service.delete_workflow(db, tenant_id, user_id, workflow_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": "Workflow not found", "code": "not_found"},
|
||||||
|
)
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Instance endpoints ───
|
||||||
|
|
||||||
|
@router.post("/{workflow_id}/instances", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_instance(
|
||||||
|
workflow_id: str,
|
||||||
|
body: InstanceCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a new workflow instance."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
|
||||||
|
result = await workflow_service.create_instance(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
context=body.context,
|
||||||
|
timeout_hours=body.timeout_hours,
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": "Workflow not found", "code": "not_found"},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances/{instance_id}")
|
||||||
|
async def get_instance(
|
||||||
|
instance_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get a workflow instance with step history."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
result = await workflow_service.get_instance(db, tenant_id, instance_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": "Instance not found", "code": "not_found"},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances/{instance_id}/advance")
|
||||||
|
async def advance_instance(
|
||||||
|
instance_id: str,
|
||||||
|
body: AdvanceRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Advance or reject a workflow instance step.
|
||||||
|
|
||||||
|
Body decision: "approve" or "reject".
|
||||||
|
Returns 200 with updated instance.
|
||||||
|
"""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
|
||||||
|
result = await workflow_service.advance_instance(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
instance_id=instance_id,
|
||||||
|
decision=body.decision,
|
||||||
|
comment=body.comment,
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": "Instance not found", "code": "not_found"},
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"detail": result["error"], "code": "invalid_state"},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances/{instance_id}/cancel")
|
||||||
|
async def cancel_instance(
|
||||||
|
instance_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Cancel a workflow instance. Returns 200 with cancelled instance."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
|
||||||
|
result = await workflow_service.cancel_instance(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
instance_id=instance_id,
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"detail": "Instance not found", "code": "not_found"},
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"detail": result["error"], "code": "invalid_state"},
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
"""Pydantic schemas package."""
|
"""Pydantic schemas package."""
|
||||||
|
|
||||||
from app.schemas.plugin import PluginInfo, PluginListResponse, PluginActionResponse, PluginUninstallResponse
|
from app.schemas.plugin import PluginInfo, PluginListResponse, PluginActionResponse, PluginUninstallResponse
|
||||||
|
from app.schemas.ai_copilot import (
|
||||||
|
CopilotQueryRequest, CopilotAction, CopilotQueryResponse,
|
||||||
|
CopilotExecuteRequest, CopilotExecuteResponse,
|
||||||
|
CopilotHistoryResponse, CopilotMessageResponse,
|
||||||
|
)
|
||||||
|
from app.schemas.workflow import (
|
||||||
|
WorkflowCreate, WorkflowUpdate, WorkflowResponse, WorkflowListResponse,
|
||||||
|
InstanceCreate, InstanceResponse, InstanceDetailResponse, InstanceListResponse,
|
||||||
|
AdvanceRequest, StepHistoryResponse, WorkflowStep,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""AI Copilot schemas — query, execute, history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotQueryRequest(BaseModel):
|
||||||
|
"""Natural language query to the AI copilot."""
|
||||||
|
query: str = Field(..., min_length=1, max_length=2000)
|
||||||
|
conversation_id: str | None = None
|
||||||
|
context: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotAction(BaseModel):
|
||||||
|
"""A proposed API action derived from NL input."""
|
||||||
|
method: str = Field(..., pattern="^(GET|POST|PATCH|DELETE)$")
|
||||||
|
path: str = Field(..., min_length=1)
|
||||||
|
body: dict | None = None
|
||||||
|
description: str = ""
|
||||||
|
confidence: float = Field(0.0, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotQueryResponse(BaseModel):
|
||||||
|
"""Response from copilot query — proposed actions for user confirmation."""
|
||||||
|
conversation_id: str
|
||||||
|
message: str
|
||||||
|
proposed_actions: list[CopilotAction] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotExecuteRequest(BaseModel):
|
||||||
|
"""Execute a proposed action after user confirmation."""
|
||||||
|
conversation_id: str
|
||||||
|
action: CopilotAction
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotExecuteResponse(BaseModel):
|
||||||
|
"""Result of executing a proposed action."""
|
||||||
|
conversation_id: str
|
||||||
|
success: bool
|
||||||
|
status_code: int
|
||||||
|
data: dict | list | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotMessageResponse(BaseModel):
|
||||||
|
"""A single message in conversation history."""
|
||||||
|
id: str
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
proposed_actions: list[dict] | None = None
|
||||||
|
executed_action: dict | None = None
|
||||||
|
execution_result: dict | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotHistoryResponse(BaseModel):
|
||||||
|
"""Paginated conversation history."""
|
||||||
|
items: list[CopilotMessageResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Workflow schemas — create, update, read, instance lifecycle, step history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStep(BaseModel):
|
||||||
|
"""A single step in a workflow definition."""
|
||||||
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
|
type: str = Field(..., pattern="^(action|approval|notification|condition)$")
|
||||||
|
config: dict = Field(default_factory=dict)
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowCreate(BaseModel):
|
||||||
|
"""Create a new workflow definition."""
|
||||||
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
|
description: str | None = None
|
||||||
|
trigger_event: str | None = None
|
||||||
|
steps: list[WorkflowStep] = Field(..., min_length=1)
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowUpdate(BaseModel):
|
||||||
|
"""Update an existing workflow definition."""
|
||||||
|
name: str | None = Field(None, max_length=200)
|
||||||
|
description: str | None = None
|
||||||
|
trigger_event: str | None = None
|
||||||
|
steps: list[WorkflowStep] | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowResponse(BaseModel):
|
||||||
|
"""Workflow definition response."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
trigger_event: str | None = None
|
||||||
|
steps: list[dict]
|
||||||
|
is_active: bool
|
||||||
|
created_by: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowListResponse(BaseModel):
|
||||||
|
"""Paginated workflow list."""
|
||||||
|
items: list[WorkflowResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceCreate(BaseModel):
|
||||||
|
"""Create a new workflow instance."""
|
||||||
|
context: dict = Field(default_factory=dict)
|
||||||
|
timeout_hours: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceResponse(BaseModel):
|
||||||
|
"""Workflow instance response with current state and history."""
|
||||||
|
id: str
|
||||||
|
workflow_id: str
|
||||||
|
status: str
|
||||||
|
current_step_index: int
|
||||||
|
context: dict
|
||||||
|
initiated_by: str | None = None
|
||||||
|
completed_at: str | None = None
|
||||||
|
timeout_hours: int | None = None
|
||||||
|
timeout_at: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceDetailResponse(InstanceResponse):
|
||||||
|
"""Instance with step history."""
|
||||||
|
history: list[dict] = Field(default_factory=list)
|
||||||
|
workflow_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceListResponse(BaseModel):
|
||||||
|
"""Paginated instance list."""
|
||||||
|
items: list[InstanceResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class AdvanceRequest(BaseModel):
|
||||||
|
"""Advance or reject a workflow instance step."""
|
||||||
|
decision: str = Field(..., pattern="^(approve|reject)$")
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StepHistoryResponse(BaseModel):
|
||||||
|
"""Step history entry."""
|
||||||
|
id: str
|
||||||
|
instance_id: str
|
||||||
|
step_index: int
|
||||||
|
step_type: str
|
||||||
|
action: str
|
||||||
|
actor_id: str | None = None
|
||||||
|
details: dict | None = None
|
||||||
|
created_at: str | None = None
|
||||||
@@ -1,3 +1,16 @@
|
|||||||
"""Service layer package."""
|
"""Service layer package."""
|
||||||
|
|
||||||
from app.services.plugin_service import PluginService, get_plugin_service
|
from app.services.plugin_service import PluginService, get_plugin_service
|
||||||
|
from app.services.ai_copilot_service import (
|
||||||
|
process_query as copilot_process_query,
|
||||||
|
execute_action as copilot_execute_action,
|
||||||
|
get_history as copilot_get_history,
|
||||||
|
)
|
||||||
|
from app.services.workflow_service import (
|
||||||
|
create_workflow, list_workflows, get_workflow,
|
||||||
|
update_workflow, delete_workflow,
|
||||||
|
create_instance, list_instances, get_instance,
|
||||||
|
advance_instance, cancel_instance,
|
||||||
|
check_timeout, auto_reject_timeout,
|
||||||
|
find_workflows_for_event, start_instance_for_event,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
"""AI Copilot service — NL query processing, action execution, RBAC, audit logging."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select, func, desc, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.ai.llm_client import get_llm_client
|
||||||
|
from app.core.audit import log_audit
|
||||||
|
from app.core.auth import check_permission, filter_fields_by_permission
|
||||||
|
from app.models.ai_conversation import AIConversation, AIMessage
|
||||||
|
from app.models.company import Company
|
||||||
|
from app.models.contact import Contact
|
||||||
|
from app.models.workflow import Workflow
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_iso(dt) -> str | None:
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return dt.isoformat() if hasattr(dt, "isoformat") else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_attr(obj, name, default=None):
|
||||||
|
try:
|
||||||
|
val = getattr(obj, name)
|
||||||
|
return val if val is not None else default
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _conversation_to_dict(c: AIConversation) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(c.id),
|
||||||
|
"title": c.title,
|
||||||
|
"context": c.context,
|
||||||
|
"created_at": _safe_iso(_get_attr(c, "created_at")),
|
||||||
|
"updated_at": _safe_iso(_get_attr(c, "updated_at")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _message_to_dict(m: AIMessage) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(m.id),
|
||||||
|
"role": m.role,
|
||||||
|
"content": m.content,
|
||||||
|
"proposed_actions": m.proposed_actions,
|
||||||
|
"executed_action": m.executed_action,
|
||||||
|
"execution_result": m.execution_result,
|
||||||
|
"created_at": _safe_iso(_get_attr(m, "created_at")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def process_query(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
query: str,
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
context: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Process a natural language query and return proposed actions.
|
||||||
|
|
||||||
|
1. Get or create conversation
|
||||||
|
2. Store user message
|
||||||
|
3. Call LLM client (mock or real) to get proposed actions
|
||||||
|
4. Store assistant message with proposed actions
|
||||||
|
5. Return response with conversation_id and proposed_actions
|
||||||
|
"""
|
||||||
|
context = context or {}
|
||||||
|
|
||||||
|
# Get or create conversation
|
||||||
|
if conversation_id:
|
||||||
|
conv_uuid = uuid.UUID(conversation_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(AIConversation).where(
|
||||||
|
AIConversation.id == conv_uuid,
|
||||||
|
AIConversation.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conversation = result.scalar_one_or_none()
|
||||||
|
if conversation is None:
|
||||||
|
return {"error": "Conversation not found", "status_code": 404}
|
||||||
|
else:
|
||||||
|
conversation = AIConversation(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
title=query[:100] if query else "Untitled",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
db.add(conversation)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(conversation)
|
||||||
|
|
||||||
|
# Get next message index
|
||||||
|
count_q = select(func.count()).select_from(
|
||||||
|
select(AIMessage).where(AIMessage.conversation_id == conversation.id).subquery()
|
||||||
|
)
|
||||||
|
count_result = await db.execute(count_q)
|
||||||
|
msg_index = count_result.scalar_one()
|
||||||
|
|
||||||
|
# Store user message
|
||||||
|
user_msg = AIMessage(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
role="user",
|
||||||
|
content=query,
|
||||||
|
message_index=msg_index,
|
||||||
|
)
|
||||||
|
db.add(user_msg)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(user_msg)
|
||||||
|
|
||||||
|
# Call LLM client
|
||||||
|
llm = get_llm_client()
|
||||||
|
llm_response = await llm.generate(query, context)
|
||||||
|
|
||||||
|
# Store assistant message
|
||||||
|
assistant_msg = AIMessage(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
role="assistant",
|
||||||
|
content=llm_response.message,
|
||||||
|
proposed_actions=llm_response.proposed_actions,
|
||||||
|
message_index=msg_index + 1,
|
||||||
|
)
|
||||||
|
db.add(assistant_msg)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(assistant_msg)
|
||||||
|
|
||||||
|
# Log to audit
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
action="query",
|
||||||
|
entity_type="ai_copilot",
|
||||||
|
entity_id=conversation.id,
|
||||||
|
changes={"query": query, "proposed_action_count": len(llm_response.proposed_actions)},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"conversation_id": str(conversation.id),
|
||||||
|
"message": llm_response.message,
|
||||||
|
"proposed_actions": llm_response.proposed_actions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_action(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
role: str,
|
||||||
|
conversation_id: str,
|
||||||
|
action: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute a proposed action with RBAC enforcement.
|
||||||
|
|
||||||
|
1. Validate conversation belongs to tenant
|
||||||
|
2. Check RBAC permissions for the action
|
||||||
|
3. Execute the action (direct DB or API call)
|
||||||
|
4. Store execution result in message
|
||||||
|
5. Log to audit
|
||||||
|
"""
|
||||||
|
conv_uuid = uuid.UUID(conversation_id)
|
||||||
|
|
||||||
|
# Validate conversation ownership
|
||||||
|
result = await db.execute(
|
||||||
|
select(AIConversation).where(
|
||||||
|
AIConversation.id == conv_uuid,
|
||||||
|
AIConversation.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conversation = result.scalar_one_or_none()
|
||||||
|
if conversation is None:
|
||||||
|
return {"error": "Conversation not found", "status_code": 404}
|
||||||
|
|
||||||
|
method = action.get("method", "GET").upper()
|
||||||
|
path = action.get("path", "")
|
||||||
|
body = action.get("body") or {}
|
||||||
|
|
||||||
|
# Determine module and action_type from path for RBAC
|
||||||
|
module, action_type = _derive_rbac_from_path(method, path)
|
||||||
|
|
||||||
|
if not check_permission(role, module, action_type):
|
||||||
|
return {
|
||||||
|
"error": "Insufficient permissions for this action",
|
||||||
|
"status_code": 403,
|
||||||
|
"success": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute the action
|
||||||
|
try:
|
||||||
|
exec_result = await _execute_api_action(db, tenant_id, user_id, method, path, body)
|
||||||
|
except Exception as exc:
|
||||||
|
exec_result = {"error": str(exc), "status_code": 500}
|
||||||
|
|
||||||
|
# Get next message index
|
||||||
|
count_q = select(func.count()).select_from(
|
||||||
|
select(AIMessage).where(AIMessage.conversation_id == conversation.id).subquery()
|
||||||
|
)
|
||||||
|
count_result = await db.execute(count_q)
|
||||||
|
msg_index = count_result.scalar_one()
|
||||||
|
|
||||||
|
# Store execution message
|
||||||
|
exec_msg = AIMessage(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
role="assistant",
|
||||||
|
content=f"Executed {method} {path}",
|
||||||
|
executed_action=action,
|
||||||
|
execution_result=exec_result,
|
||||||
|
message_index=msg_index,
|
||||||
|
)
|
||||||
|
db.add(exec_msg)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(exec_msg)
|
||||||
|
|
||||||
|
# Log to audit
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
action="execute",
|
||||||
|
entity_type="ai_copilot",
|
||||||
|
entity_id=conversation.id,
|
||||||
|
changes={"action": action, "result": exec_result},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"conversation_id": str(conversation.id),
|
||||||
|
"success": exec_result.get("success", True),
|
||||||
|
"status_code": exec_result.get("status_code", 200),
|
||||||
|
"data": exec_result.get("data"),
|
||||||
|
"error": exec_result.get("error"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_history(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get paginated conversation history for the current user."""
|
||||||
|
page = max(1, page)
|
||||||
|
page_size = max(1, min(100, page_size))
|
||||||
|
|
||||||
|
base = select(AIConversation).where(
|
||||||
|
AIConversation.tenant_id == tenant_id,
|
||||||
|
AIConversation.user_id == user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
count_q = select(func.count()).select_from(base.subquery())
|
||||||
|
total_result = await db.execute(count_q)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
paginated = base.order_by(desc(AIConversation.created_at)).offset(offset).limit(page_size)
|
||||||
|
result = await db.execute(paginated)
|
||||||
|
conversations = result.scalars().all()
|
||||||
|
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for conv in conversations:
|
||||||
|
# Get messages for each conversation
|
||||||
|
msg_q = select(AIMessage).where(
|
||||||
|
AIMessage.conversation_id == conv.id,
|
||||||
|
AIMessage.tenant_id == tenant_id,
|
||||||
|
).order_by(AIMessage.message_index)
|
||||||
|
msg_result = await db.execute(msg_q)
|
||||||
|
messages = msg_result.scalars().all()
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
**_conversation_to_dict(conv),
|
||||||
|
"messages": [_message_to_dict(m) for m in messages],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_api_action(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
body: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute an API action directly against the database.
|
||||||
|
|
||||||
|
Supports companies and contacts CRUD, plus workflow listing.
|
||||||
|
"""
|
||||||
|
# Parse path to determine entity and operation
|
||||||
|
parts = path.replace("/api/v1/", "").strip("/").split("/")
|
||||||
|
entity = parts[0] if parts else ""
|
||||||
|
entity_id = parts[1] if len(parts) > 1 else None
|
||||||
|
|
||||||
|
if entity == "companies":
|
||||||
|
return await _exec_companies(db, tenant_id, user_id, method, entity_id, body)
|
||||||
|
elif entity == "contacts":
|
||||||
|
return await _exec_contacts(db, tenant_id, user_id, method, entity_id, body)
|
||||||
|
elif entity == "workflows":
|
||||||
|
return await _exec_workflows(db, tenant_id, user_id, method, entity_id, body)
|
||||||
|
else:
|
||||||
|
return {"error": f"Unsupported entity: {entity}", "status_code": 400, "success": False}
|
||||||
|
|
||||||
|
|
||||||
|
async def _exec_companies(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
method: str,
|
||||||
|
entity_id: str | None,
|
||||||
|
body: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute company operations."""
|
||||||
|
if method == "GET":
|
||||||
|
result = await db.execute(
|
||||||
|
select(Company).where(
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
Company.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
companies = result.scalars().all()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status_code": 200,
|
||||||
|
"data": [
|
||||||
|
{"id": str(c.id), "name": c.name, "industry": c.industry}
|
||||||
|
for c in companies
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
elif method == "POST":
|
||||||
|
company = Company(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=body.get("name", "Untitled"),
|
||||||
|
industry=body.get("industry"),
|
||||||
|
phone=body.get("phone"),
|
||||||
|
email=body.get("email"),
|
||||||
|
website=body.get("website"),
|
||||||
|
description=body.get("description"),
|
||||||
|
created_by=user_id,
|
||||||
|
updated_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(company)
|
||||||
|
await db.flush()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status_code": 201,
|
||||||
|
"data": {"id": str(company.id), "name": company.name},
|
||||||
|
}
|
||||||
|
|
||||||
|
elif method == "DELETE":
|
||||||
|
if not entity_id or entity_id == "{id}":
|
||||||
|
return {"error": "Company ID required", "status_code": 400, "success": False}
|
||||||
|
comp_uuid = uuid.UUID(entity_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Company).where(
|
||||||
|
Company.id == comp_uuid,
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
company = result.scalar_one_or_none()
|
||||||
|
if company is None:
|
||||||
|
return {"error": "Company not found", "status_code": 404, "success": False}
|
||||||
|
company.deleted_at = datetime.now(timezone.utc)
|
||||||
|
await db.flush()
|
||||||
|
return {"success": True, "status_code": 200, "data": {"id": str(company.id), "deleted": True}}
|
||||||
|
|
||||||
|
elif method == "PATCH":
|
||||||
|
if not entity_id or entity_id == "{id}":
|
||||||
|
return {"error": "Company ID required", "status_code": 400, "success": False}
|
||||||
|
comp_uuid = uuid.UUID(entity_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Company).where(
|
||||||
|
Company.id == comp_uuid,
|
||||||
|
Company.tenant_id == tenant_id,
|
||||||
|
Company.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
company = result.scalar_one_or_none()
|
||||||
|
if company is None:
|
||||||
|
return {"error": "Company not found", "status_code": 404, "success": False}
|
||||||
|
for key in ("name", "industry", "phone", "email", "website", "description"):
|
||||||
|
if key in body:
|
||||||
|
setattr(company, key, body[key])
|
||||||
|
company.updated_by = user_id
|
||||||
|
await db.flush()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status_code": 200,
|
||||||
|
"data": {"id": str(company.id), "name": company.name, "industry": company.industry},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
||||||
|
|
||||||
|
|
||||||
|
async def _exec_contacts(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
method: str,
|
||||||
|
entity_id: str | None,
|
||||||
|
body: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute contact operations."""
|
||||||
|
if method == "GET":
|
||||||
|
result = await db.execute(
|
||||||
|
select(Contact).where(
|
||||||
|
Contact.tenant_id == tenant_id,
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
contacts = result.scalars().all()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status_code": 200,
|
||||||
|
"data": [
|
||||||
|
{"id": str(c.id), "name": c.name, "email": c.email}
|
||||||
|
for c in contacts
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
elif method == "POST":
|
||||||
|
contact = Contact(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=body.get("name", "Untitled"),
|
||||||
|
email=body.get("email"),
|
||||||
|
phone=body.get("phone"),
|
||||||
|
created_by=user_id,
|
||||||
|
updated_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(contact)
|
||||||
|
await db.flush()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status_code": 201,
|
||||||
|
"data": {"id": str(contact.id), "name": contact.name},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
||||||
|
|
||||||
|
|
||||||
|
async def _exec_workflows(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
method: str,
|
||||||
|
entity_id: str | None,
|
||||||
|
body: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute workflow operations."""
|
||||||
|
if method == "GET":
|
||||||
|
result = await db.execute(
|
||||||
|
select(Workflow).where(
|
||||||
|
Workflow.tenant_id == tenant_id,
|
||||||
|
Workflow.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workflows = result.scalars().all()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status_code": 200,
|
||||||
|
"data": [
|
||||||
|
{"id": str(w.id), "name": w.name, "trigger_event": w.trigger_event}
|
||||||
|
for w in workflows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_rbac_from_path(method: str, path: str) -> tuple[str, str]:
|
||||||
|
"""Derive module and action_type from HTTP method and path for RBAC.
|
||||||
|
|
||||||
|
Returns (module, action_type) suitable for check_permission().
|
||||||
|
"""
|
||||||
|
parts = path.replace("/api/v1/", "").strip("/").split("/")
|
||||||
|
entity = parts[0] if parts else ""
|
||||||
|
|
||||||
|
method_to_action = {
|
||||||
|
"GET": "read",
|
||||||
|
"POST": "create",
|
||||||
|
"PATCH": "update",
|
||||||
|
"DELETE": "delete",
|
||||||
|
}
|
||||||
|
action_type = method_to_action.get(method, "read")
|
||||||
|
|
||||||
|
# Map path entities to permission modules
|
||||||
|
entity_to_module = {
|
||||||
|
"companies": "companies",
|
||||||
|
"contacts": "contacts",
|
||||||
|
"workflows": "workflows",
|
||||||
|
"ai": "ai_copilot",
|
||||||
|
}
|
||||||
|
module = entity_to_module.get(entity, entity)
|
||||||
|
|
||||||
|
return module, action_type
|
||||||
@@ -0,0 +1,675 @@
|
|||||||
|
"""Workflow service — CRUD, instance lifecycle, step transitions, event triggers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select, func, desc, and_, or_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.audit import log_audit
|
||||||
|
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
||||||
|
from app.models.notification import Notification
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_iso(dt) -> str | None:
|
||||||
|
"""Safely convert datetime to ISO string, handling unloaded attributes."""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return dt.isoformat() if hasattr(dt, "isoformat") else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_attr(obj, name, default=None):
|
||||||
|
"""Safely get an attribute that might be expired/unloaded in async context."""
|
||||||
|
try:
|
||||||
|
val = getattr(obj, name)
|
||||||
|
return val if val is not None else default
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_to_dict(w: Workflow) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(w.id),
|
||||||
|
"name": w.name,
|
||||||
|
"description": w.description,
|
||||||
|
"trigger_event": w.trigger_event,
|
||||||
|
"steps": w.steps,
|
||||||
|
"is_active": w.is_active,
|
||||||
|
"created_by": str(w.created_by) if w.created_by else None,
|
||||||
|
"created_at": _safe_iso(_get_attr(w, "created_at")),
|
||||||
|
"updated_at": _safe_iso(_get_attr(w, "updated_at")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _instance_to_dict(i: WorkflowInstance, include_history: bool = False, history: list | None = None, workflow_name: str | None = None) -> dict[str, Any]:
|
||||||
|
data = {
|
||||||
|
"id": str(i.id),
|
||||||
|
"workflow_id": str(i.workflow_id),
|
||||||
|
"status": i.status,
|
||||||
|
"current_step_index": i.current_step_index,
|
||||||
|
"context": i.context,
|
||||||
|
"initiated_by": str(i.initiated_by) if i.initiated_by else None,
|
||||||
|
"completed_at": _safe_iso(_get_attr(i, "completed_at")),
|
||||||
|
"timeout_hours": i.timeout_hours,
|
||||||
|
"timeout_at": _safe_iso(_get_attr(i, "timeout_at")),
|
||||||
|
"created_at": _safe_iso(_get_attr(i, "created_at")),
|
||||||
|
"updated_at": _safe_iso(_get_attr(i, "updated_at")),
|
||||||
|
}
|
||||||
|
if include_history:
|
||||||
|
data["history"] = history or []
|
||||||
|
data["workflow_name"] = workflow_name
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _history_to_dict(h: WorkflowStepHistory) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(h.id),
|
||||||
|
"instance_id": str(h.instance_id),
|
||||||
|
"step_index": h.step_index,
|
||||||
|
"step_type": h.step_type,
|
||||||
|
"action": h.action,
|
||||||
|
"actor_id": str(h.actor_id) if h.actor_id else None,
|
||||||
|
"details": h.details,
|
||||||
|
"created_at": _safe_iso(_get_attr(h, "created_at")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _log_step_history(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
step_index: int,
|
||||||
|
step_type: str,
|
||||||
|
action: str,
|
||||||
|
actor_id: uuid.UUID | None = None,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> WorkflowStepHistory:
|
||||||
|
"""Create a workflow step history entry."""
|
||||||
|
entry = WorkflowStepHistory(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
instance_id=instance_id,
|
||||||
|
step_index=step_index,
|
||||||
|
step_type=step_type,
|
||||||
|
action=action,
|
||||||
|
actor_id=actor_id,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
await db.flush()
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Workflow CRUD ───
|
||||||
|
|
||||||
|
async def create_workflow(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new workflow definition."""
|
||||||
|
steps_raw = data.get("steps", [])
|
||||||
|
# Validate steps
|
||||||
|
if not steps_raw:
|
||||||
|
raise ValueError("Workflow must have at least one step")
|
||||||
|
|
||||||
|
steps_json = [s if isinstance(s, dict) else s.model_dump() for s in steps_raw]
|
||||||
|
|
||||||
|
workflow = Workflow(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=data["name"],
|
||||||
|
description=data.get("description"),
|
||||||
|
trigger_event=data.get("trigger_event"),
|
||||||
|
steps=steps_json,
|
||||||
|
is_active=data.get("is_active", True),
|
||||||
|
created_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(workflow)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
action="create",
|
||||||
|
entity_type="workflow",
|
||||||
|
entity_id=workflow.id,
|
||||||
|
changes={"name": workflow.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
return _workflow_to_dict(workflow)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_workflows(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List workflows with pagination."""
|
||||||
|
page = max(1, page)
|
||||||
|
page_size = max(1, min(100, page_size))
|
||||||
|
|
||||||
|
base = select(Workflow).where(Workflow.tenant_id == tenant_id)
|
||||||
|
if is_active is not None:
|
||||||
|
base = base.where(Workflow.is_active == is_active)
|
||||||
|
|
||||||
|
count_q = select(func.count()).select_from(base.subquery())
|
||||||
|
total_result = await db.execute(count_q)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
paginated = base.order_by(desc(Workflow.created_at)).offset(offset).limit(page_size)
|
||||||
|
result = await db.execute(paginated)
|
||||||
|
workflows = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [_workflow_to_dict(w) for w in workflows],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_workflow(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
workflow_id: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Get a single workflow by ID."""
|
||||||
|
wf_uuid = uuid.UUID(workflow_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Workflow).where(
|
||||||
|
Workflow.id == wf_uuid,
|
||||||
|
Workflow.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workflow = result.scalar_one_or_none()
|
||||||
|
if workflow is None:
|
||||||
|
return None
|
||||||
|
return _workflow_to_dict(workflow)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_workflow(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
workflow_id: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Update a workflow definition."""
|
||||||
|
wf_uuid = uuid.UUID(workflow_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Workflow).where(
|
||||||
|
Workflow.id == wf_uuid,
|
||||||
|
Workflow.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workflow = result.scalar_one_or_none()
|
||||||
|
if workflow is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "name" in data:
|
||||||
|
workflow.name = data["name"]
|
||||||
|
if "description" in data:
|
||||||
|
workflow.description = data["description"]
|
||||||
|
if "trigger_event" in data:
|
||||||
|
workflow.trigger_event = data["trigger_event"]
|
||||||
|
if "steps" in data:
|
||||||
|
steps_raw = data["steps"]
|
||||||
|
workflow.steps = [s if isinstance(s, dict) else s.model_dump() for s in steps_raw]
|
||||||
|
if "is_active" in data:
|
||||||
|
workflow.is_active = data["is_active"]
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
action="update",
|
||||||
|
entity_type="workflow",
|
||||||
|
entity_id=workflow.id,
|
||||||
|
changes={"updated_fields": list(data.keys())},
|
||||||
|
)
|
||||||
|
|
||||||
|
return _workflow_to_dict(workflow)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_workflow(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
workflow_id: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Delete a workflow definition."""
|
||||||
|
wf_uuid = uuid.UUID(workflow_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Workflow).where(
|
||||||
|
Workflow.id == wf_uuid,
|
||||||
|
Workflow.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workflow = result.scalar_one_or_none()
|
||||||
|
if workflow is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await db.delete(workflow)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
action="delete",
|
||||||
|
entity_type="workflow",
|
||||||
|
entity_id=wf_uuid,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Instance Lifecycle ───
|
||||||
|
|
||||||
|
async def create_instance(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
workflow_id: str,
|
||||||
|
context: dict[str, Any] | None = None,
|
||||||
|
timeout_hours: int | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Create a new workflow instance with status=pending."""
|
||||||
|
wf_uuid = uuid.UUID(workflow_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Workflow).where(
|
||||||
|
Workflow.id == wf_uuid,
|
||||||
|
Workflow.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workflow = result.scalar_one_or_none()
|
||||||
|
if workflow is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
timeout_at = None
|
||||||
|
if timeout_hours:
|
||||||
|
timeout_at = datetime.now(timezone.utc) + timedelta(hours=timeout_hours)
|
||||||
|
|
||||||
|
instance = WorkflowInstance(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
workflow_id=wf_uuid,
|
||||||
|
status="pending",
|
||||||
|
current_step_index=0,
|
||||||
|
context=context or {},
|
||||||
|
initiated_by=user_id,
|
||||||
|
timeout_hours=timeout_hours,
|
||||||
|
timeout_at=timeout_at,
|
||||||
|
)
|
||||||
|
db.add(instance)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(instance)
|
||||||
|
|
||||||
|
# Log initial step entry
|
||||||
|
steps = workflow.steps or []
|
||||||
|
first_step = steps[0] if steps else {}
|
||||||
|
await _log_step_history(
|
||||||
|
db, tenant_id, instance.id,
|
||||||
|
step_index=0,
|
||||||
|
step_type=first_step.get("type", "action"),
|
||||||
|
action="entered",
|
||||||
|
actor_id=user_id,
|
||||||
|
details={"workflow_name": workflow.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
action="create",
|
||||||
|
entity_type="workflow_instance",
|
||||||
|
entity_id=instance.id,
|
||||||
|
changes={"workflow_id": str(wf_uuid), "status": "pending"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return _instance_to_dict(instance)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_instances(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
status_filter: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List workflow instances with optional status filter."""
|
||||||
|
page = max(1, page)
|
||||||
|
page_size = max(1, min(100, page_size))
|
||||||
|
|
||||||
|
base = select(WorkflowInstance).where(WorkflowInstance.tenant_id == tenant_id)
|
||||||
|
if status_filter:
|
||||||
|
base = base.where(WorkflowInstance.status == status_filter)
|
||||||
|
|
||||||
|
count_q = select(func.count()).select_from(base.subquery())
|
||||||
|
total_result = await db.execute(count_q)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
paginated = base.order_by(desc(WorkflowInstance.created_at)).offset(offset).limit(page_size)
|
||||||
|
result = await db.execute(paginated)
|
||||||
|
instances = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [_instance_to_dict(i) for i in instances],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_instance(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
instance_id: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Get a single workflow instance with step history."""
|
||||||
|
inst_uuid = uuid.UUID(instance_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(WorkflowInstance).where(
|
||||||
|
WorkflowInstance.id == inst_uuid,
|
||||||
|
WorkflowInstance.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
instance = result.scalar_one_or_none()
|
||||||
|
if instance is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get workflow name
|
||||||
|
wf_result = await db.execute(
|
||||||
|
select(Workflow.name).where(Workflow.id == instance.workflow_id)
|
||||||
|
)
|
||||||
|
workflow_name = wf_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Get step history
|
||||||
|
hist_q = select(WorkflowStepHistory).where(
|
||||||
|
WorkflowStepHistory.instance_id == inst_uuid,
|
||||||
|
WorkflowStepHistory.tenant_id == tenant_id,
|
||||||
|
).order_by(WorkflowStepHistory.created_at)
|
||||||
|
hist_result = await db.execute(hist_q)
|
||||||
|
history = hist_result.scalars().all()
|
||||||
|
|
||||||
|
return _instance_to_dict(
|
||||||
|
instance,
|
||||||
|
include_history=True,
|
||||||
|
history=[_history_to_dict(h) for h in history],
|
||||||
|
workflow_name=workflow_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def advance_instance(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
instance_id: str,
|
||||||
|
decision: str,
|
||||||
|
comment: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Advance or reject a workflow instance step.
|
||||||
|
|
||||||
|
decision: "approve" or "reject"
|
||||||
|
- approve: move to next step or complete if last step
|
||||||
|
- reject: set status=rejected, notify initiator
|
||||||
|
"""
|
||||||
|
inst_uuid = uuid.UUID(instance_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(WorkflowInstance).where(
|
||||||
|
WorkflowInstance.id == inst_uuid,
|
||||||
|
WorkflowInstance.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
instance = result.scalar_one_or_none()
|
||||||
|
if instance is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if instance.status not in ("pending", "in_progress"):
|
||||||
|
return {"error": f"Cannot advance instance with status {instance.status}", "status_code": 400}
|
||||||
|
|
||||||
|
# Get workflow definition
|
||||||
|
wf_result = await db.execute(
|
||||||
|
select(Workflow).where(Workflow.id == instance.workflow_id)
|
||||||
|
)
|
||||||
|
workflow = wf_result.scalar_one_or_none()
|
||||||
|
if workflow is None:
|
||||||
|
return {"error": "Workflow definition not found", "status_code": 404}
|
||||||
|
|
||||||
|
steps = workflow.steps or []
|
||||||
|
current_idx = instance.current_step_index
|
||||||
|
current_step = steps[current_idx] if current_idx < len(steps) else None
|
||||||
|
step_type = current_step.get("type", "action") if current_step else "action"
|
||||||
|
|
||||||
|
if decision == "reject":
|
||||||
|
instance.status = "rejected"
|
||||||
|
instance.completed_at = datetime.now(timezone.utc)
|
||||||
|
await _log_step_history(
|
||||||
|
db, tenant_id, instance.id,
|
||||||
|
step_index=current_idx,
|
||||||
|
step_type=step_type,
|
||||||
|
action="rejected",
|
||||||
|
actor_id=user_id,
|
||||||
|
details={"comment": comment},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify initiator
|
||||||
|
if instance.initiated_by:
|
||||||
|
notification = Notification(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=instance.initiated_by,
|
||||||
|
type="workflow_rejected",
|
||||||
|
title=f"Workflow '{workflow.name}' rejected",
|
||||||
|
body=comment or f"Step {current_idx + 1} was rejected",
|
||||||
|
)
|
||||||
|
db.add(notification)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
action="reject",
|
||||||
|
entity_type="workflow_instance",
|
||||||
|
entity_id=instance.id,
|
||||||
|
changes={"step": current_idx, "comment": comment},
|
||||||
|
)
|
||||||
|
|
||||||
|
return _instance_to_dict(instance)
|
||||||
|
|
||||||
|
# decision == approve
|
||||||
|
# Move to in_progress if was pending
|
||||||
|
if instance.status == "pending":
|
||||||
|
instance.status = "in_progress"
|
||||||
|
|
||||||
|
# Log approval
|
||||||
|
await _log_step_history(
|
||||||
|
db, tenant_id, instance.id,
|
||||||
|
step_index=current_idx,
|
||||||
|
step_type=step_type,
|
||||||
|
action="approved",
|
||||||
|
actor_id=user_id,
|
||||||
|
details={"comment": comment},
|
||||||
|
)
|
||||||
|
|
||||||
|
next_idx = current_idx + 1
|
||||||
|
if next_idx >= len(steps):
|
||||||
|
# Workflow complete
|
||||||
|
instance.status = "completed"
|
||||||
|
instance.completed_at = datetime.now(timezone.utc)
|
||||||
|
await _log_step_history(
|
||||||
|
db, tenant_id, instance.id,
|
||||||
|
step_index=current_idx,
|
||||||
|
step_type="complete",
|
||||||
|
action="completed",
|
||||||
|
actor_id=user_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
instance.current_step_index = next_idx
|
||||||
|
next_step = steps[next_idx]
|
||||||
|
await _log_step_history(
|
||||||
|
db, tenant_id, instance.id,
|
||||||
|
step_index=next_idx,
|
||||||
|
step_type=next_step.get("type", "action"),
|
||||||
|
action="entered",
|
||||||
|
actor_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
action="advance",
|
||||||
|
entity_type="workflow_instance",
|
||||||
|
entity_id=instance.id,
|
||||||
|
changes={"decision": decision, "step": current_idx, "next_step": next_idx if next_idx < len(steps) else None},
|
||||||
|
)
|
||||||
|
|
||||||
|
return _instance_to_dict(instance)
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_instance(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
instance_id: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Cancel a workflow instance."""
|
||||||
|
inst_uuid = uuid.UUID(instance_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(WorkflowInstance).where(
|
||||||
|
WorkflowInstance.id == inst_uuid,
|
||||||
|
WorkflowInstance.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
instance = result.scalar_one_or_none()
|
||||||
|
if instance is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if instance.status in ("completed", "rejected", "cancelled"):
|
||||||
|
return {"error": f"Cannot cancel instance with status {instance.status}", "status_code": 400}
|
||||||
|
|
||||||
|
instance.status = "cancelled"
|
||||||
|
instance.completed_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Get current step for history
|
||||||
|
wf_result = await db.execute(
|
||||||
|
select(Workflow).where(Workflow.id == instance.workflow_id)
|
||||||
|
)
|
||||||
|
workflow = wf_result.scalar_one_or_none()
|
||||||
|
steps = workflow.steps if workflow else []
|
||||||
|
current_step = steps[instance.current_step_index] if instance.current_step_index < len(steps) else {}
|
||||||
|
|
||||||
|
await _log_step_history(
|
||||||
|
db, tenant_id, instance.id,
|
||||||
|
step_index=instance.current_step_index,
|
||||||
|
step_type=current_step.get("type", "action"),
|
||||||
|
action="cancelled",
|
||||||
|
actor_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
action="cancel",
|
||||||
|
entity_type="workflow_instance",
|
||||||
|
entity_id=instance.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _instance_to_dict(instance)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_timeout(instance: WorkflowInstance) -> bool:
|
||||||
|
"""Check if an instance has timed out (for approval steps).
|
||||||
|
|
||||||
|
Returns True if the instance should be auto-rejected.
|
||||||
|
"""
|
||||||
|
if instance.timeout_at is None:
|
||||||
|
return False
|
||||||
|
if instance.status not in ("pending", "in_progress"):
|
||||||
|
return False
|
||||||
|
return datetime.now(timezone.utc) > instance.timeout_at
|
||||||
|
|
||||||
|
|
||||||
|
async def auto_reject_timeout(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
instance: WorkflowInstance,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Auto-reject a timed-out instance."""
|
||||||
|
instance.status = "rejected"
|
||||||
|
instance.completed_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
wf_result = await db.execute(
|
||||||
|
select(Workflow).where(Workflow.id == instance.workflow_id)
|
||||||
|
)
|
||||||
|
workflow = wf_result.scalar_one_or_none()
|
||||||
|
steps = workflow.steps if workflow else []
|
||||||
|
current_step = steps[instance.current_step_index] if instance.current_step_index < len(steps) else {}
|
||||||
|
|
||||||
|
await _log_step_history(
|
||||||
|
db, tenant_id, instance.id,
|
||||||
|
step_index=instance.current_step_index,
|
||||||
|
step_type=current_step.get("type", "approval"),
|
||||||
|
action="auto_rejected",
|
||||||
|
details={"reason": "timeout"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify initiator
|
||||||
|
if instance.initiated_by:
|
||||||
|
notification = Notification(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=instance.initiated_by,
|
||||||
|
type="workflow_timeout",
|
||||||
|
title=f"Workflow '{workflow.name if workflow else 'Unknown'}' auto-rejected",
|
||||||
|
body="The approval step timed out and was automatically rejected.",
|
||||||
|
)
|
||||||
|
db.add(notification)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(instance)
|
||||||
|
return _instance_to_dict(instance)
|
||||||
|
|
||||||
|
|
||||||
|
async def find_workflows_for_event(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
event_name: str,
|
||||||
|
) -> list[Workflow]:
|
||||||
|
"""Find active workflows that trigger on a specific event."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Workflow).where(
|
||||||
|
Workflow.tenant_id == tenant_id,
|
||||||
|
Workflow.is_active.is_(True),
|
||||||
|
Workflow.trigger_event == event_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def start_instance_for_event(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID | None,
|
||||||
|
event_name: str,
|
||||||
|
context: dict[str, Any] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Start workflow instances for all workflows matching an event trigger.
|
||||||
|
|
||||||
|
Called by the event bus when an event is published.
|
||||||
|
"""
|
||||||
|
workflows = await find_workflows_for_event(db, tenant_id, event_name)
|
||||||
|
instances: list[dict[str, Any]] = []
|
||||||
|
for wf in workflows:
|
||||||
|
inst = await create_instance(
|
||||||
|
db, tenant_id,
|
||||||
|
user_id or uuid.uuid4(),
|
||||||
|
str(wf.id),
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
if inst:
|
||||||
|
instances.append(inst)
|
||||||
|
return instances
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Workflow engine package — execution engine and code-engine workflows."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Code-engine workflows — hardcoded workflow definitions."""
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Onboarding workflow — runs on user creation.
|
||||||
|
|
||||||
|
A code-engine workflow that creates a welcome notification and an
|
||||||
|
approval step for admin confirmation of new users.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.services.workflow_service import create_instance
|
||||||
|
|
||||||
|
# Onboarding workflow definition (code-engine — hardcoded steps)
|
||||||
|
ONBOARDING_WORKFLOW_STEPS = [
|
||||||
|
{
|
||||||
|
"name": "Send welcome notification",
|
||||||
|
"type": "notification",
|
||||||
|
"config": {
|
||||||
|
"notification_type": "welcome",
|
||||||
|
"title": "Welcome to LeoCRM!",
|
||||||
|
"body": "Your account has been created. An admin will approve your access shortly.",
|
||||||
|
},
|
||||||
|
"description": "Send a welcome notification to the new user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Admin approval",
|
||||||
|
"type": "approval",
|
||||||
|
"config": {
|
||||||
|
"required_role": "admin",
|
||||||
|
"description": "Admin must approve the new user account",
|
||||||
|
},
|
||||||
|
"description": "Admin reviews and approves the new user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send confirmation",
|
||||||
|
"type": "notification",
|
||||||
|
"config": {
|
||||||
|
"notification_type": "onboarding_complete",
|
||||||
|
"title": "Account approved",
|
||||||
|
"body": "Your account has been approved. You can now use LeoCRM.",
|
||||||
|
},
|
||||||
|
"description": "Notify user that their account is approved",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_onboarding_workflow_definition() -> dict[str, Any]:
|
||||||
|
"""Return the onboarding workflow definition for DB seeding."""
|
||||||
|
return {
|
||||||
|
"name": "User Onboarding",
|
||||||
|
"description": "Automated onboarding workflow triggered on user creation",
|
||||||
|
"trigger_event": "user.created",
|
||||||
|
"steps": ONBOARDING_WORKFLOW_STEPS,
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_onboarding_workflow_exists(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> uuid.UUID | None:
|
||||||
|
"""Ensure the onboarding workflow exists in the DB for this tenant.
|
||||||
|
|
||||||
|
If it doesn't exist yet, create it. Returns the workflow ID.
|
||||||
|
"""
|
||||||
|
from app.models.workflow import Workflow
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Workflow).where(
|
||||||
|
Workflow.tenant_id == tenant_id,
|
||||||
|
Workflow.trigger_event == "user.created",
|
||||||
|
Workflow.name == "User Onboarding",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
return existing.id
|
||||||
|
|
||||||
|
from app.services.workflow_service import create_workflow
|
||||||
|
wf_dict = await create_workflow(
|
||||||
|
db, tenant_id, user_id,
|
||||||
|
get_onboarding_workflow_definition(),
|
||||||
|
)
|
||||||
|
return uuid.UUID(wf_dict["id"]) if wf_dict else None
|
||||||
|
|
||||||
|
|
||||||
|
async def trigger_onboarding(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
admin_user_id: uuid.UUID,
|
||||||
|
new_user_id: uuid.UUID,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Trigger the onboarding workflow for a newly created user.
|
||||||
|
|
||||||
|
1. Ensure the onboarding workflow exists in DB
|
||||||
|
2. Create a workflow instance with new_user_id in context
|
||||||
|
"""
|
||||||
|
wf_id = await ensure_onboarding_workflow_exists(db, tenant_id, admin_user_id)
|
||||||
|
if wf_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
instance = await create_instance(
|
||||||
|
db, tenant_id, admin_user_id,
|
||||||
|
str(wf_id),
|
||||||
|
context={"new_user_id": str(new_user_id), "user_id": str(new_user_id)},
|
||||||
|
)
|
||||||
|
return instance
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""Workflow execution engine — step processing, conditions, approvals.
|
||||||
|
|
||||||
|
Processes workflow instances by evaluating steps sequentially.
|
||||||
|
Supports step types: action, approval, notification, condition.
|
||||||
|
Integrates with the event bus for event-triggered workflows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.event_bus import get_event_bus
|
||||||
|
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.services.workflow_service import (
|
||||||
|
_log_step_history,
|
||||||
|
_instance_to_dict,
|
||||||
|
create_instance,
|
||||||
|
find_workflows_for_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowEngine:
|
||||||
|
"""Processes workflow instances through their defined steps.
|
||||||
|
|
||||||
|
Step types:
|
||||||
|
- action: Executes a configured action (e.g. create entity, send notification)
|
||||||
|
- approval: Pauses and waits for user approve/reject via API
|
||||||
|
- notification: Sends a notification to specified users
|
||||||
|
- condition: Evaluates a condition and branches accordingly
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession, tenant_id: uuid.UUID):
|
||||||
|
self.db = db
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
|
||||||
|
async def process_step(self, instance: WorkflowInstance) -> dict[str, Any]:
|
||||||
|
"""Process the current step of a workflow instance.
|
||||||
|
|
||||||
|
For action/notification/condition steps: executes and advances.
|
||||||
|
For approval steps: sets status to in_progress and waits.
|
||||||
|
Returns the updated instance dict.
|
||||||
|
"""
|
||||||
|
wf_result = await self.db.execute(
|
||||||
|
select(Workflow).where(Workflow.id == instance.workflow_id)
|
||||||
|
)
|
||||||
|
workflow = wf_result.scalar_one_or_none()
|
||||||
|
if workflow is None:
|
||||||
|
return {"error": "Workflow not found", "status_code": 404}
|
||||||
|
|
||||||
|
steps = workflow.steps or []
|
||||||
|
if instance.current_step_index >= len(steps):
|
||||||
|
instance.status = "completed"
|
||||||
|
instance.completed_at = datetime.now(timezone.utc)
|
||||||
|
await self.db.flush()
|
||||||
|
return _instance_to_dict(instance)
|
||||||
|
|
||||||
|
step = steps[instance.current_step_index]
|
||||||
|
step_type = step.get("type", "action")
|
||||||
|
|
||||||
|
# Log step entry
|
||||||
|
await _log_step_history(
|
||||||
|
self.db, self.tenant_id, instance.id,
|
||||||
|
step_index=instance.current_step_index,
|
||||||
|
step_type=step_type,
|
||||||
|
action="processing",
|
||||||
|
details={"step_name": step.get("name")},
|
||||||
|
)
|
||||||
|
|
||||||
|
if step_type == "approval":
|
||||||
|
# Approval steps pause and wait for user input
|
||||||
|
if instance.status == "pending":
|
||||||
|
instance.status = "in_progress"
|
||||||
|
await self.db.flush()
|
||||||
|
return _instance_to_dict(instance)
|
||||||
|
|
||||||
|
elif step_type == "notification":
|
||||||
|
return await self._process_notification(instance, step)
|
||||||
|
|
||||||
|
elif step_type == "condition":
|
||||||
|
return await self._process_condition(instance, step, steps)
|
||||||
|
|
||||||
|
elif step_type == "action":
|
||||||
|
return await self._process_action(instance, step, steps)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"error": f"Unknown step type: {step_type}", "status_code": 400}
|
||||||
|
|
||||||
|
async def _process_action(
|
||||||
|
self, instance: WorkflowInstance, step: dict, steps: list
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Process an action step — executes the configured action and advances."""
|
||||||
|
config = step.get("config", {})
|
||||||
|
action_type = config.get("action_type", "noop")
|
||||||
|
|
||||||
|
# Execute action based on type
|
||||||
|
if action_type == "create_notification":
|
||||||
|
user_id = config.get("user_id") or (str(instance.initiated_by) if instance.initiated_by else None)
|
||||||
|
if user_id:
|
||||||
|
notification = Notification(
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
user_id=uuid.UUID(user_id),
|
||||||
|
type=config.get("notification_type", "workflow_action"),
|
||||||
|
title=config.get("title", "Workflow notification"),
|
||||||
|
body=config.get("body", ""),
|
||||||
|
)
|
||||||
|
self.db.add(notification)
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
elif action_type == "noop":
|
||||||
|
pass # No operation — just advance
|
||||||
|
|
||||||
|
# Log action executed
|
||||||
|
await _log_step_history(
|
||||||
|
self.db, self.tenant_id, instance.id,
|
||||||
|
step_index=instance.current_step_index,
|
||||||
|
step_type="action",
|
||||||
|
action="executed",
|
||||||
|
details={"action_type": action_type},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Advance to next step
|
||||||
|
next_idx = instance.current_step_index + 1
|
||||||
|
if next_idx >= len(steps):
|
||||||
|
instance.status = "completed"
|
||||||
|
instance.completed_at = datetime.now(timezone.utc)
|
||||||
|
else:
|
||||||
|
instance.current_step_index = next_idx
|
||||||
|
instance.status = "in_progress"
|
||||||
|
|
||||||
|
await self.db.flush()
|
||||||
|
return _instance_to_dict(instance)
|
||||||
|
|
||||||
|
async def _process_notification(
|
||||||
|
self, instance: WorkflowInstance, step: dict
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Process a notification step — sends notification and advances."""
|
||||||
|
config = step.get("config", {})
|
||||||
|
user_id = config.get("user_id") or (str(instance.initiated_by) if instance.initiated_by else None)
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
notification = Notification(
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
user_id=uuid.UUID(user_id),
|
||||||
|
type=config.get("notification_type", "workflow_notification"),
|
||||||
|
title=config.get("title", "Workflow notification"),
|
||||||
|
body=config.get("body", ""),
|
||||||
|
)
|
||||||
|
self.db.add(notification)
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
await _log_step_history(
|
||||||
|
self.db, self.tenant_id, instance.id,
|
||||||
|
step_index=instance.current_step_index,
|
||||||
|
step_type="notification",
|
||||||
|
action="sent",
|
||||||
|
details={"user_id": user_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Advance — notification steps auto-advance
|
||||||
|
wf_result = await self.db.execute(
|
||||||
|
select(Workflow).where(Workflow.id == instance.workflow_id)
|
||||||
|
)
|
||||||
|
workflow = wf_result.scalar_one_or_none()
|
||||||
|
steps = workflow.steps if workflow else []
|
||||||
|
next_idx = instance.current_step_index + 1
|
||||||
|
if next_idx >= len(steps):
|
||||||
|
instance.status = "completed"
|
||||||
|
instance.completed_at = datetime.now(timezone.utc)
|
||||||
|
else:
|
||||||
|
instance.current_step_index = next_idx
|
||||||
|
|
||||||
|
await self.db.flush()
|
||||||
|
return _instance_to_dict(instance)
|
||||||
|
|
||||||
|
async def _process_condition(
|
||||||
|
self, instance: WorkflowInstance, step: dict, steps: list
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Process a condition step — evaluates condition and branches.
|
||||||
|
|
||||||
|
Config format:
|
||||||
|
{
|
||||||
|
"field": "context_key",
|
||||||
|
"operator": "eq|ne|gt|lt|contains",
|
||||||
|
"value": "expected_value",
|
||||||
|
"on_true_step": optional_index,
|
||||||
|
"on_false_step": optional_index
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
config = step.get("config", {})
|
||||||
|
field = config.get("field", "")
|
||||||
|
operator = config.get("operator", "eq")
|
||||||
|
expected = config.get("value")
|
||||||
|
actual = instance.context.get(field)
|
||||||
|
|
||||||
|
condition_met = False
|
||||||
|
if operator == "eq":
|
||||||
|
condition_met = actual == expected
|
||||||
|
elif operator == "ne":
|
||||||
|
condition_met = actual != expected
|
||||||
|
elif operator == "gt":
|
||||||
|
condition_met = actual is not None and expected is not None and actual > expected
|
||||||
|
elif operator == "lt":
|
||||||
|
condition_met = actual is not None and expected is not None and actual < expected
|
||||||
|
elif operator == "contains":
|
||||||
|
condition_met = actual is not None and expected in actual if isinstance(actual, (str, list)) else False
|
||||||
|
|
||||||
|
await _log_step_history(
|
||||||
|
self.db, self.tenant_id, instance.id,
|
||||||
|
step_index=instance.current_step_index,
|
||||||
|
step_type="condition",
|
||||||
|
action="evaluated",
|
||||||
|
details={"field": field, "operator": operator, "condition_met": condition_met},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Branch or advance
|
||||||
|
if condition_met and "on_true_step" in config:
|
||||||
|
instance.current_step_index = config["on_true_step"]
|
||||||
|
elif not condition_met and "on_false_step" in config:
|
||||||
|
instance.current_step_index = config["on_false_step"]
|
||||||
|
else:
|
||||||
|
next_idx = instance.current_step_index + 1
|
||||||
|
if next_idx >= len(steps):
|
||||||
|
instance.status = "completed"
|
||||||
|
instance.completed_at = datetime.now(timezone.utc)
|
||||||
|
else:
|
||||||
|
instance.current_step_index = next_idx
|
||||||
|
|
||||||
|
await self.db.flush()
|
||||||
|
return _instance_to_dict(instance)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_event(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
event_name: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Handle an event by starting matching workflow instances.
|
||||||
|
|
||||||
|
Called by the event bus integration. Finds all active workflows
|
||||||
|
with trigger_event matching event_name and creates instances.
|
||||||
|
"""
|
||||||
|
workflows = await find_workflows_for_event(db, tenant_id, event_name)
|
||||||
|
instances: list[dict[str, Any]] = []
|
||||||
|
for wf in workflows:
|
||||||
|
inst = await create_instance(
|
||||||
|
db, tenant_id,
|
||||||
|
uuid.UUID(payload.get("user_id", str(uuid.uuid4()))) if payload.get("user_id") else None or uuid.uuid4(),
|
||||||
|
str(wf.id),
|
||||||
|
context=payload,
|
||||||
|
)
|
||||||
|
if inst:
|
||||||
|
instances.append(inst)
|
||||||
|
return instances
|
||||||
|
|
||||||
|
|
||||||
|
def register_workflow_event_handlers() -> None:
|
||||||
|
"""Register event bus handlers for workflow triggers.
|
||||||
|
|
||||||
|
Subscribes to the event bus to auto-start workflows when events fire.
|
||||||
|
Should be called during application startup.
|
||||||
|
"""
|
||||||
|
event_bus = get_event_bus()
|
||||||
|
|
||||||
|
async def _workflow_event_handler(payload: dict[str, Any]) -> None:
|
||||||
|
"""Handle events that may trigger workflows."""
|
||||||
|
from app.core.db import create_db_session
|
||||||
|
tenant_id_str = payload.get("tenant_id")
|
||||||
|
event_name = payload.get("event", "")
|
||||||
|
if not tenant_id_str or not event_name:
|
||||||
|
return
|
||||||
|
|
||||||
|
tenant_id = uuid.UUID(tenant_id_str)
|
||||||
|
async with create_db_session(tenant_id) as db:
|
||||||
|
await handle_event(db, tenant_id, event_name, payload)
|
||||||
|
|
||||||
|
# Subscribe to common events
|
||||||
|
for event_name in ("user.created", "company.created", "contact.created"):
|
||||||
|
event_bus.subscribe(event_name, _workflow_event_handler)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# T09 Test Report — Coverage Improvement
|
||||||
|
|
||||||
|
## Date: 2026-06-29
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- **Previous**: 30 tests, 22 ACs passing, 45.37% coverage
|
||||||
|
- **Current**: 135 T09 tests passing (105 new), 238 total regression passing, **84.12%** overall coverage
|
||||||
|
- **Target**: 80%+ — **ACHIEVED**
|
||||||
|
|
||||||
|
## T09 Coverage Results
|
||||||
|
|
||||||
|
```
|
||||||
|
---------- coverage: platform linux, python 3.13.12-final-0 ----------
|
||||||
|
Name Stmts Miss Branch BrPart Cover Missing
|
||||||
|
---------------------------------------------------------------------------------
|
||||||
|
app/ai/action_mapper.py 76 0 46 4 96.72%
|
||||||
|
app/ai/llm_client.py 59 11 6 1 81.54%
|
||||||
|
app/routes/ai_copilot.py 34 8 6 0 65.00%
|
||||||
|
app/routes/workflows.py 92 25 24 0 62.93%
|
||||||
|
app/services/ai_copilot_service.py 172 3 44 0 98.61%
|
||||||
|
app/services/workflow_service.py 231 45 60 17 75.95%
|
||||||
|
app/workflows/code/__init__.py 0 0 0 0 100.00%
|
||||||
|
app/workflows/code/onboarding.py 24 2 4 2 85.71%
|
||||||
|
app/workflows/engine.py 130 9 50 7 90.00%
|
||||||
|
---------------------------------------------------------------------------------
|
||||||
|
TOTAL 818 103 240 31 84.12%
|
||||||
|
```
|
||||||
|
|
||||||
|
## Per-Module Coverage Improvement
|
||||||
|
|
||||||
|
| Module | Before | After | Delta |
|
||||||
|
|--------|--------|-------|-------|
|
||||||
|
| app/workflows/engine.py | 0% | 90.00% | +90.00% |
|
||||||
|
| app/services/ai_copilot_service.py | 38.89% | 98.61% | +59.72% |
|
||||||
|
| app/ai/action_mapper.py | 43.44% | 96.72% | +53.28% |
|
||||||
|
| app/routes/workflows.py | 59.48% | 62.93% | +3.45% |
|
||||||
|
| app/ai/llm_client.py | 64.62% | 81.54% | +16.92% |
|
||||||
|
| app/routes/ai_copilot.py | 65% | 65.00% | +0.00% |
|
||||||
|
| app/services/workflow_service.py | 62.54% | 75.95% | +13.41% |
|
||||||
|
| **Overall** | **45.37%** | **84.12%** | **+38.75%** |
|
||||||
|
|
||||||
|
## New Tests Added (105 total)
|
||||||
|
|
||||||
|
### test_workflows.py (48 new tests)
|
||||||
|
- **WorkflowEngine unit tests (22)**: action noop, create_notification, completion, approval step, notification step, condition eq/ne/gt/lt/contains (string+list), no-match advance, condition completion, unknown step type, workflow not found, step index beyond steps, handle_event (match + no-match), register_workflow_event_handlers
|
||||||
|
- **Route error path tests (9)**: update/delete workflow 404, update/delete RBAC 403, create instance 404, advance/cancel 404, advance completed 400, cancel rejected 400
|
||||||
|
- **Service edge case tests (9)**: check_timeout no_timeout_at, check_timeout completed status, auto_reject no initiated_by, find_workflows no match, start_instance no match, list_workflows active filter, update_workflow with steps, update/delete/get not found
|
||||||
|
|
||||||
|
### test_ai_copilot.py (57 new tests)
|
||||||
|
- **ActionMapper unit tests (18)**: create/delete/update/list company, create/list contact, list/create workflow, help intent, unknown query, update with phone/email, update no fields, company list all pattern, empty query
|
||||||
|
- **LLMClient unit tests (11)**: mock generate, mock no actions, mock with context, API mode init, api_base default, LLMResponse.to_dict, parse valid/invalid JSON, build system prompt (with/without context), get/reset singleton
|
||||||
|
- **AI Copilot service tests (20)**: process_query (new/existing/invalid/empty conversation), execute_action (companies GET/POST/PATCH/delete + error paths, contacts GET/POST/unsupported, workflows GET/unsupported, unknown entity, invalid conversation, RBAC blocked), get_history (pagination/empty), _derive_rbac_from_path (5 entity paths), _safe_iso (3 cases), _get_attr (2 cases)
|
||||||
|
- **Route error path tests (4)**: execute 404, execute RBAC 403, history/execute unauthenticated 401
|
||||||
|
|
||||||
|
## Test Commands
|
||||||
|
|
||||||
|
### T09 Coverage Suite
|
||||||
|
```bash
|
||||||
|
cd /a0/usr/workdir/dev-projects/leocrm && python -m pytest tests/test_ai_copilot.py tests/test_workflows.py --cov=app.ai --cov=app.workflows --cov=app.services.ai_copilot_service --cov=app.services.workflow_service --cov=app.routes.ai_copilot --cov=app.routes.workflows --cov-report=term-missing --tb=short -v
|
||||||
|
```
|
||||||
|
**Result**: 135 passed, coverage 84.12%
|
||||||
|
|
||||||
|
### Full Regression
|
||||||
|
```bash
|
||||||
|
cd /a0/usr/workdir/dev-projects/leocrm && python -m pytest tests/ -v --tb=short
|
||||||
|
```
|
||||||
|
**Result**: 238 passed, 0 failed, 2 warnings (pre-existing deprecation warning)
|
||||||
|
|
||||||
|
## Smoke Test
|
||||||
|
- T09 coverage suite: 135/135 tests pass
|
||||||
|
- Full regression: 238/238 tests pass (133 existing + 105 new)
|
||||||
|
- No production code modified — only test files updated
|
||||||
|
- All 22 original ACs still pass
|
||||||
|
nREPORT
|
||||||
|
echo 'test_report.md created'
|
||||||
+3
-1
@@ -33,6 +33,8 @@ from app.models.role import Role
|
|||||||
from app.models.company import Company
|
from app.models.company import Company
|
||||||
from app.models.contact import Contact, CompanyContact
|
from app.models.contact import Contact, CompanyContact
|
||||||
from app.models.plugin import Plugin, PluginMigration
|
from app.models.plugin import Plugin, PluginMigration
|
||||||
|
from app.models.ai_conversation import AIConversation, AIMessage
|
||||||
|
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
|
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ def clean_tables(db_setup):
|
|||||||
sync_eng = _get_sync_engine()
|
sync_eng = _get_sync_engine()
|
||||||
with sync_eng.connect() as conn:
|
with sync_eng.connect() as conn:
|
||||||
# TRUNCATE all tables with CASCADE — fast and reliable isolation
|
# TRUNCATE all tables with CASCADE — fast and reliable isolation
|
||||||
conn.execute(text("TRUNCATE TABLE plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"))
|
conn.execute(text("TRUNCATE TABLE workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
sync_eng.dispose()
|
sync_eng.dispose()
|
||||||
yield
|
yield
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user