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:
|
||||
"""Get the global 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.service_container import get_container
|
||||
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
|
||||
@@ -57,6 +57,8 @@ def create_app() -> FastAPI:
|
||||
app.include_router(contacts.router)
|
||||
app.include_router(import_export.router)
|
||||
app.include_router(plugins.router)
|
||||
app.include_router(ai_copilot.router)
|
||||
app.include_router(workflows.router)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -18,3 +18,15 @@ __all__ = [
|
||||
"Contact", "CompanyContact",
|
||||
"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."""
|
||||
|
||||
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."""
|
||||
|
||||
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."""
|
||||
|
||||
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.contact import Contact, CompanyContact
|
||||
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
|
||||
|
||||
|
||||
@@ -88,7 +90,7 @@ def clean_tables(db_setup):
|
||||
sync_eng = _get_sync_engine()
|
||||
with sync_eng.connect() as conn:
|
||||
# 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()
|
||||
sync_eng.dispose()
|
||||
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