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:
leocrm-bot
2026-06-29 02:44:13 +02:00
parent 7a5a48fb4c
commit 14bd4e33fb
31 changed files with 5884 additions and 3 deletions
+133
View File
@@ -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
+121
View File
@@ -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
+15
View File
@@ -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
+26
View File
@@ -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
+36
View File
@@ -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
+126
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""AI Copilot modules — LLM client and action mapper."""
+199
View File
@@ -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'}
+173
View File
@@ -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
+10
View File
@@ -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
View File
@@ -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
+12
View File
@@ -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",
]
+53
View File
@@ -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)
+89
View File
@@ -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 -1
View File
@@ -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
+102
View File
@@ -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,
)
+249
View File
@@ -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
+10
View File
@@ -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,
)
+62
View File
@@ -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
+105
View File
@@ -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
+13
View File
@@ -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,
)
+507
View File
@@ -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
+675
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Workflow engine package — execution engine and code-engine workflows."""
+1
View File
@@ -0,0 +1 @@
"""Code-engine workflows — hardcoded workflow definitions."""
+112
View File
@@ -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
+288
View File
@@ -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)
+75
View File
@@ -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
View File
@@ -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