Files
leocrm/app/plugins/builtins/ai_assistant/crm_api_tool.py
T

190 lines
7.0 KiB
Python
Raw Normal View History

"""Generic CRM API tool — gives AI direct access to all CRM REST endpoints.
Instead of registering dozens of individual tools, this single tool lets the AI
call any CRM API endpoint. The OpenAPI spec is injected into the system prompt
so the AI knows which endpoints exist and what parameters they accept.
"""
from __future__ import annotations
import json
import logging
from typing import Any
import httpx
logger = logging.getLogger(__name__)
# Cache for OpenAPI spec
_openapi_cache: dict[str, Any] | None = None
def _get_base_url() -> str:
"""Get the internal base URL for API calls."""
import os
port = os.environ.get("PORT", "8000")
return f"http://127.0.0.1:{port}"
async def get_openapi_spec() -> dict[str, Any]:
"""Get the CRM OpenAPI spec, cached."""
global _openapi_cache
if _openapi_cache is not None:
return _openapi_cache
try:
base_url = _get_base_url()
async with httpx.AsyncClient() as client:
resp = await client.get(f"{base_url}/openapi.json", timeout=10.0)
if resp.status_code == 200:
spec = resp.json()
# Compact the spec: only keep paths, methods, summary, parameters
compact: dict[str, Any] = {"paths": {}}
for path, methods in spec.get("paths", {}).items():
compact["paths"][path] = {}
for method, details in methods.items():
if method in ("get", "post", "patch", "put", "delete"):
compact["paths"][path][method] = {
"summary": details.get("summary", ""),
"description": details.get("description", "")[:200],
"parameters": [
{
"name": p.get("name", ""),
"in": p.get("in", ""),
"required": p.get("required", False),
"schema": p.get("schema", {}),
}
for p in details.get("parameters", [])
],
"requestBody": details.get("requestBody", {}).get("content", {}),
}
_openapi_cache = compact
return _openapi_cache
except Exception as e:
logger.warning("Failed to fetch OpenAPI spec: %s", e)
_openapi_cache = {"paths": {}}
return _openapi_cache
async def get_api_context_for_prompt() -> str:
"""Get a compact text summary of available API endpoints for the system prompt."""
spec = await get_openapi_spec()
lines: list[str] = ["\n\n## Verfügbare CRM API Endpunkte\n"]
lines.append("Du kannst jeden dieser Endpunkte mit dem Tool 'call_crm_api' aufrufen.")
lines.append("Parameter: method (GET/POST/PATCH/DELETE), path (z.B. /api/v1/contacts), body (JSON für POST/PATCH).\n")
for path, methods in spec.get("paths", {}).items():
for method, details in methods.items():
summary = details.get("summary", "")
lines.append(f"- {method.upper()} {path}: {summary}")
return "\n".join(lines)
async def call_crm_api_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
"""Execute an arbitrary CRM API call on behalf of the AI.
The AI provides method, path, and optional body. The handler makes an
internal HTTP request with the user's session context (tenant_id, user_id)
and returns the response.
"""
method = arguments.get("method", "GET").upper()
path = arguments.get("path", "")
body = arguments.get("body")
if not path:
return json.dumps({"error": "path is required"})
# Ensure path starts with /
if not path.startswith("/"):
path = "/" + path
try:
base_url = _get_base_url()
# Get user context for auth
tenant_id = context.get("tenant_id", "")
user_id = context.get("user_id", "")
# Create a DB session to resolve a valid session token for this user
# We'll use internal service-level auth bypass
headers = {
"Content-Type": "application/json",
"X-Internal-Call": "true",
"X-Tenant-Id": str(tenant_id),
"X-User-Id": str(user_id),
}
async with httpx.AsyncClient() as client:
if method == "GET":
resp = await client.get(f"{base_url}{path}", headers=headers, timeout=30.0)
elif method == "POST":
resp = await client.post(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
elif method == "PATCH":
resp = await client.patch(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
elif method == "PUT":
resp = await client.put(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
elif method == "DELETE":
resp = await client.delete(f"{base_url}{path}", headers=headers, timeout=30.0)
else:
return json.dumps({"error": f"Unsupported method: {method}"})
# Return response body (truncated if too large)
try:
resp_data = resp.json()
resp_text = json.dumps(resp_data, default=str)
if len(resp_text) > 8000:
resp_text = resp_text[:8000] + "\n... [truncated]"
return resp_text
except Exception:
return resp.text[:8000]
except Exception as e:
logger.exception("call_crm_api failed")
return json.dumps({"error": str(e)})
# Tool definition for the registry
TOOL_NAME = "call_crm_api"
TOOL_DESCRIPTION = (
"Rufe einen beliebigen CRM API Endpunkt auf. Verwende GET zum Lesen, "
"POST zum Erstellen, PATCH zum Aktualisieren, DELETE zum Löschen. "
"Der path muss mit /api/v1/ beginnen (z.B. /api/v1/contacts). "
"Für POST/PATCH kann ein body (JSON Objekt) mitgegeben werden."
)
TOOL_PARAMETERS = {
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"default": "GET",
"description": "HTTP Methode",
},
"path": {
"type": "string",
"description": "API Pfad, z.B. /api/v1/contacts oder /api/v1/contacts/{id}",
},
"body": {
"type": "object",
"description": "Request body für POST/PATCH (JSON Objekt)",
},
},
"required": ["method", "path"],
}
def register_crm_api_tool(registry) -> None:
"""Register the generic CRM API tool."""
registry.register(
name=TOOL_NAME,
description=TOOL_DESCRIPTION,
parameters=TOOL_PARAMETERS,
handler=call_crm_api_handler,
plugin_name="ai_assistant",
required_permission='ai:write', # Uses internal auth context
category="system",
)
logger.info("CRM API tool registered — AI can now call any CRM endpoint")