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
+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)