Files
leocrm/app/plugins/builtins/ai_proactive/context_tools.py
T

336 lines
12 KiB
Python
Raw Normal View History

"""AI Tools for the Tool Registry — context-aware tools registered
by the ai_proactive plugin for use by the AI Assistant.
Each tool is an async function (arguments: dict, context: dict) -> str
that performs SQL queries and returns JSON-string results.
"""
from __future__ import annotations
import json
import logging
import uuid
from typing import Any
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import create_db_session
from app.models.audit import AuditLog
from app.models.contact import Contact
from app.plugins.builtins.mail.models import Mail
logger = logging.getLogger(__name__)
def _serialize(row: Any) -> dict[str, Any]:
"""Serialize a SQLAlchemy model instance to a dict."""
if row is None:
return {}
result: dict[str, Any] = {}
for column in row.__table__.columns:
val = getattr(row, column.name)
if hasattr(val, "isoformat"):
result[column.name] = val.isoformat()
elif isinstance(val, uuid.UUID):
result[column.name] = str(val)
else:
result[column.name] = val
return result
async def _get_db_and_tenant(context: dict[str, Any]) -> tuple[AsyncSession, uuid.UUID, uuid.UUID]:
"""Extract tenant_id, user_id from context and create a DB session."""
tenant_id = uuid.UUID(context.get("tenant_id", ""))
user_id = uuid.UUID(context.get("user_id", ""))
db = context.get("db")
if db is not None and isinstance(db, AsyncSession):
return db, tenant_id, user_id
# Fallback: create standalone session
raise RuntimeError("No db session in context")
# ─── Tool Handlers ───
async def get_contact_mails_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
"""Get all mails for a contact, ordered by received_at DESC."""
try:
db, tenant_id, _ = await _get_db_and_tenant(context)
contact_id = uuid.UUID(arguments["contact_id"])
limit = arguments.get("limit", 10)
result = await db.execute(
select(Mail)
.where(Mail.contact_id == contact_id)
.where(Mail.tenant_id == tenant_id)
.order_by(Mail.received_at.desc())
.limit(limit)
)
mails = [_serialize(m) for m in result.scalars().all()]
return json.dumps({"mails": mails, "count": len(mails)}, default=str)
except Exception as e:
logger.exception("get_contact_mails_handler failed")
return json.dumps({"error": str(e)})
async def get_contact_history_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
"""Get audit log history for an entity, ordered by timestamp DESC."""
try:
db, tenant_id, _ = await _get_db_and_tenant(context)
entity_id = uuid.UUID(arguments["entity_id"])
limit = arguments.get("limit", 20)
result = await db.execute(
select(AuditLog)
.where(AuditLog.entity_id == entity_id)
.where(AuditLog.tenant_id == tenant_id)
.order_by(AuditLog.timestamp.desc())
.limit(limit)
)
entries = [_serialize(a) for a in result.scalars().all()]
return json.dumps({"activities": entries, "count": len(entries)}, default=str)
except Exception as e:
logger.exception("get_contact_history_handler failed")
return json.dumps({"error": str(e)})
async def search_related_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
"""Find semantically similar entities via unified_search."""
try:
db, tenant_id, _ = await _get_db_and_tenant(context)
entity_type = arguments["entity_type"]
entity_id = uuid.UUID(arguments["entity_id"])
limit = arguments.get("limit", 5)
from app.plugins.builtins.unified_search.search_engine import (
find_similar_all_types,
)
similar = await find_similar_all_types(
db, entity_type, entity_id, tenant_id, limit=limit
)
return json.dumps({"similar": similar}, default=str)
except Exception as e:
logger.exception("search_related_handler failed")
return json.dumps({"error": str(e)})
async def summarize_mail_thread_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
"""Summarize a mail thread using LLM."""
try:
import litellm
db, tenant_id, _ = await _get_db_and_tenant(context)
thread_id = arguments["thread_id"]
limit = arguments.get("limit", 20)
result = await db.execute(
select(Mail)
.where(Mail.thread_id == thread_id)
.where(Mail.tenant_id == tenant_id)
.order_by(Mail.received_at.asc())
.limit(limit)
)
mails = result.scalars().all()
if not mails:
return json.dumps({"summary": "No mails found in thread", "count": 0})
thread_text = "\n\n".join(
f"From: {m.from_address}\nSubject: {m.subject}\nDate: {m.received_at}\nBody: {m.body_text[:500]}"
for m in mails
)
response = await litellm.acompletion(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "Fasse diesen E-Mail-Thread präzise zusammen. Antworte auf Deutsch.",
},
{"role": "user", "content": thread_text},
],
temperature=0.3,
max_tokens=300,
)
summary = response.choices[0].message.content or ""
return json.dumps({"summary": summary, "count": len(mails)}, default=str)
except Exception as e:
logger.exception("summarize_mail_thread_handler failed")
return json.dumps({"error": str(e)})
async def get_open_tasks_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
"""Get open tasks (calendar entries) for a contact or company."""
try:
from datetime import UTC, datetime
from app.plugins.builtins.calendar.models import (
CalendarEntry,
CalendarEntryLink,
)
db, tenant_id, _ = await _get_db_and_tenant(context)
entity_type = arguments["entity_type"]
entity_id = uuid.UUID(arguments["entity_id"])
now = datetime.now(UTC)
result = await db.execute(
select(CalendarEntry)
.join(CalendarEntryLink, CalendarEntryLink.entry_id == CalendarEntry.id)
.where(CalendarEntryLink.entity_type == entity_type)
.where(CalendarEntryLink.entity_id == entity_id)
.where(CalendarEntry.tenant_id == tenant_id)
.where(CalendarEntry.start_at > now)
.where(CalendarEntry.status == "open")
.order_by(CalendarEntry.start_at.asc())
.limit(20)
)
tasks = [_serialize(e) for e in result.scalars().all()]
return json.dumps({"tasks": tasks, "count": len(tasks)}, default=str)
except Exception as e:
logger.exception("get_open_tasks_handler failed")
return json.dumps({"error": str(e)})
async def hybrid_search_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
"""Perform hybrid search via unified_search search_engine."""
try:
from app.plugins.builtins.unified_search.search_engine import hybrid_search
db, tenant_id, _ = await _get_db_and_tenant(context)
query = arguments["query"]
entity_types = arguments.get("entity_types")
limit = arguments.get("limit", 20)
query_analysis = {
"normalized_query": query,
"semantic_terms": [],
}
results = await hybrid_search(
db, query_analysis, tenant_id, entity_types=entity_types, limit=limit
)
return json.dumps({"results": results, "count": len(results)}, default=str)
except Exception as e:
logger.exception("hybrid_search_handler failed")
return json.dumps({"error": str(e)})
# ─── Registration ───
def register_context_tools(registry) -> None:
"""Register AI tools into the existing Tool Registry."""
# Tool 1: get_contact_mails
registry.register(
name="get_contact_mails",
description="Alle Mails eines Kontakts abrufen, sortiert nach Datum absteigend",
parameters={
"type": "object",
"properties": {
"contact_id": {"type": "string", "description": "UUID des Kontakts"},
"limit": {"type": "integer", "default": 10, "description": "Max results"},
},
"required": ["contact_id"],
},
handler=get_contact_mails_handler,
plugin_name="ai_proactive",
required_permission="mail:read",
category="context",
)
# Tool 2: get_contact_history
registry.register(
name="get_contact_history",
description="Aktivitätsverlauf (Audit Log) für eine Entity abrufen",
parameters={
"type": "object",
"properties": {
"entity_id": {"type": "string", "description": "UUID der Entity"},
"limit": {"type": "integer", "default": 20, "description": "Max results"},
},
"required": ["entity_id"],
},
handler=get_contact_history_handler,
plugin_name="ai_proactive",
required_permission="ai_proactive:read",
category="context",
)
# Tool 3: search_related
registry.register(
name="search_related",
description="Semantisch ähnliche Entities über unified_search finden",
parameters={
"type": "object",
"properties": {
"entity_type": {"type": "string", "description": "contact, company, mail, file, event"},
"entity_id": {"type": "string", "description": "UUID der Entity"},
"limit": {"type": "integer", "default": 5, "description": "Max results per type"},
},
"required": ["entity_type", "entity_id"],
},
handler=search_related_handler,
plugin_name="ai_proactive",
required_permission="search:read",
category="context",
)
# Tool 4: summarize_mail_thread
registry.register(
name="summarize_mail_thread",
description="E-Mail-Thread per LLM zusammenfassen",
parameters={
"type": "object",
"properties": {
"thread_id": {"type": "string", "description": "Thread ID"},
"limit": {"type": "integer", "default": 20, "description": "Max mails to include"},
},
"required": ["thread_id"],
},
handler=summarize_mail_thread_handler,
plugin_name="ai_proactive",
required_permission="mail:read",
category="context",
)
# Tool 5: get_open_tasks
registry.register(
name="get_open_tasks",
description="Offene Aufgaben (Kalender-Termine) für einen Kontakt oder eine Firma abrufen",
parameters={
"type": "object",
"properties": {
"entity_type": {"type": "string", "description": "contact or company"},
"entity_id": {"type": "string", "description": "UUID der Entity"},
},
"required": ["entity_type", "entity_id"],
},
handler=get_open_tasks_handler,
plugin_name="ai_proactive",
required_permission="ai_proactive:read",
category="context",
)
# Tool 6: hybrid_search
registry.register(
name="hybrid_search",
description="Hybride Volltext- und Semantiksuche über alle CRM-Entities via unified_search",
parameters={
"type": "object",
"properties": {
"query": {"type": "string", "description": "Suchanfrage"},
"entity_types": {
"type": "array",
"items": {"type": "string"},
"description": "Optional: filter by entity types",
},
"limit": {"type": "integer", "default": 20, "description": "Max results"},
},
"required": ["query"],
},
handler=hybrid_search_handler,
plugin_name="ai_proactive",
required_permission="search:read",
category="context",
)