"""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", )