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