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 9678344f0e
commit 851e7999ba
31 changed files with 5884 additions and 3 deletions
+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