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:
@@ -0,0 +1 @@
|
||||
"""AI Copilot modules — LLM client and action mapper."""
|
||||
@@ -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'}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user