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