8cebb4f4e9
- unified_search: Hybride Suche (PostgreSQL FTS + pgvector + RRF Fusion) - 5 Search Providers (Contact, Company, Mail, File, Event) - KI Query Understanding (Fuzzy, Facetten via LiteLLM) - DMS Text-Extraction (PDF, DOCX, XLSX, PPTX) - Embedding Pipeline (ollama/nomic-embed-text, 768 Dim) - Background Jobs für Indexierung - Plugin-basierte Provider Registry - ai_proactive: Proaktiver KI-Agent - Context-Tracking (Frontend → Backend → Event Bus) - Proactive Engine mit LLM Suggestion-Generierung - SSE Real-time Push an Frontend - 6 AI Tools für Tool Registry - Rate-Limiting + User Settings - Deep Analysis Background Jobs - Frontend Integration: - useAIContext Hook, SuggestionSidebar, SuggestionBadge - ProactiveAISettings Page, Search API Client - Globale Suche auf neue API umgestellt - Tests: test_unified_search.py + test_ai_proactive.py (alle bestanden) - Config: Ollama Cloud DeepSeek V4 als Default, konfigurierbar - Dependencies: PyMuPDF, python-docx, python-pptx, pgvector - Bugfixes: notification type_key length, migration IF NOT EXISTS
336 lines
12 KiB
Python
336 lines
12 KiB
Python
"""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",
|
|
)
|