851e7999ba
- 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
174 lines
6.8 KiB
Python
174 lines
6.8 KiB
Python
"""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
|