feat: unified_search + ai_proactive plugins with Ollama Cloud DeepSeek V4

- 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
This commit is contained in:
Agent Zero
2026-07-18 11:21:51 +02:00
parent 4c9295de21
commit 8cebb4f4e9
52 changed files with 6493 additions and 41 deletions
@@ -0,0 +1,7 @@
"""Proactive AI plugin package."""
from __future__ import annotations
from app.plugins.builtins.ai_proactive.plugin import AIProactivePlugin
__all__ = ["AIProactivePlugin"]
@@ -0,0 +1,335 @@
"""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",
)
@@ -0,0 +1,157 @@
import { useProactiveSettings } from './api';
const categoryLabels: Record<string, string> = {
mail: 'E-Mails',
tasks: 'Aufgaben',
contacts: 'Kontakte',
companies: 'Firmen',
insights: 'Erkenntnisse',
};
const modelOptions = [
{ value: 'ollama/deepseek-v4', label: 'DeepSeek V4 (empfohlen)' },
{ value: 'ollama/deepseek-v4-pro', label: 'DeepSeek V4 Pro (präzise)' },
{ value: 'ollama/llama3.2', label: 'Llama 3.2 (Alternative)' },
{ value: 'ollama/gpt-4o-mini', label: 'GPT-4o mini via Ollama' },
{ value: 'gpt-4o-mini', label: 'GPT-4o mini (Direct OpenAI)' },
];
export function AISettings() {
const { settings, update } = useProactiveSettings();
if (!settings) {
return (
<div className="flex items-center justify-center p-8 text-gray-400">
<div className="animate-pulse">Lade Einstellungen...</div>
</div>
);
}
const toggleCategory = async (cat: string) => {
const current = settings.suggestion_categories || [];
const newCats = current.includes(cat)
? current.filter((c: string) => c !== cat)
: [...current, cat];
await update({ suggestion_categories: newCats });
};
return (
<div className="max-w-2xl space-y-6">
{/* Enable/Disable */}
<div className="flex items-center justify-between p-4 bg-white rounded-lg border border-gray-200">
<div>
<h3 className="font-semibold text-gray-800">Proaktive KI</h3>
<p className="text-sm text-gray-500">Aktiviert oder deaktiviert alle Vorschläge</p>
</div>
<button
onClick={() => update({ enabled: !settings.enabled })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.enabled ? 'bg-blue-500' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Categories */}
<div className="p-4 bg-white rounded-lg border border-gray-200">
<h3 className="font-semibold text-gray-800 mb-3">Kategorien</h3>
<p className="text-sm text-gray-500 mb-4">Für welche Bereiche sollen Vorschläge generiert werden?</p>
<div className="grid grid-cols-2 gap-3">
{Object.entries(categoryLabels).map(([key, label]) => {
const checked = settings.suggestion_categories?.includes(key);
return (
<label
key={key}
className={`flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${
checked ? 'bg-blue-50 border-blue-300' : 'bg-gray-50 border-gray-200 hover:bg-gray-100'
}`}
>
<input
type="checkbox"
checked={checked || false}
onChange={() => toggleCategory(key)}
className="w-4 h-4 rounded text-blue-500 focus:ring-blue-400"
/>
<span className="text-sm text-gray-700">{label}</span>
</label>
);
})}
</div>
</div>
{/* Confidence Threshold */}
<div className="p-4 bg-white rounded-lg border border-gray-200">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-gray-800">Confidence Schwellwert</h3>
<span className="text-sm font-mono text-blue-600">
{Math.round((settings.confidence_threshold || 0.5) * 100)}%
</span>
</div>
<p className="text-sm text-gray-500 mb-3">
Nur Vorschläge mit höherer Confidence anzeigen
</p>
<input
type="range"
min="0"
max="1"
step="0.05"
value={settings.confidence_threshold || 0.5}
onChange={(e) => update({ confidence_threshold: parseFloat(e.target.value) })}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>Alle</span>
<span>Sehr sicher</span>
</div>
</div>
{/* Rate Limit */}
<div className="p-4 bg-white rounded-lg border border-gray-200">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-gray-800">Rate Limit</h3>
<span className="text-sm font-mono text-blue-600">
{settings.rate_limit_seconds || 10}s
</span>
</div>
<p className="text-sm text-gray-500 mb-3">
Mindestabstand zwischen Vorschlägen
</p>
<input
type="range"
min="5"
max="60"
step="5"
value={settings.rate_limit_seconds || 10}
onChange={(e) => update({ rate_limit_seconds: parseInt(e.target.value) })}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>5s</span>
<span>60s</span>
</div>
</div>
{/* Model Selection */}
<div className="p-4 bg-white rounded-lg border border-gray-200">
<h3 className="font-semibold text-gray-800 mb-2">KI Modell (Ollama Cloud)</h3>
<p className="text-sm text-gray-500 mb-3">Wähle das Modell für Vorschläge. DeepSeek V4 ist empfohlen für beste Performance/Kosten.</p>
<select
value={settings.model || 'ollama/deepseek-v4'}
onChange={(e) => update({ model: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:ring-2 focus:ring-blue-400 focus:border-blue-400 outline-none"
>
{modelOptions.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
);
}
@@ -0,0 +1,59 @@
import { useState, useEffect } from 'react';
import { apiClient } from '@/api/client';
interface SuggestionBadgeProps {
onClick: () => void;
}
export function SuggestionBadge({ onClick }: SuggestionBadgeProps) {
const [count, setCount] = useState(0);
const [pulsing, setPulsing] = useState(false);
useEffect(() => {
// Initial count
apiClient.get('/api/v1/ai-proactive/suggestions').then(r => {
setCount(r.data.items.length);
}).catch(() => {});
// SSE for real-time updates
const eventSource = new EventSource('/api/v1/ai-proactive/suggestions/stream');
eventSource.onmessage = () => {
setCount(prev => prev + 1);
setPulsing(true);
setTimeout(() => setPulsing(false), 2000);
};
return () => eventSource.close();
}, []);
if (count === 0) {
return (
<button
onClick={onClick}
className="relative p-2 text-gray-500 hover:text-gray-700 transition-colors"
title="KI Vorschläge"
>
🤖
</button>
);
}
return (
<button
onClick={onClick}
className={`relative p-2 text-gray-500 hover:text-gray-700 transition-colors ${
pulsing ? 'animate-pulse' : ''
}`}
title={`${count} KI Vorschläge`}
>
🤖
<span
className={`absolute -top-0 -right-0 min-w-[18px] h-[18px] flex items-center justify-center text-[10px] font-bold text-white rounded-full ${
pulsing ? 'bg-red-500 animate-bounce' : 'bg-blue-500'
}`}
>
{count > 99 ? '99+' : count}
</span>
</button>
);
}
@@ -0,0 +1,94 @@
import { useState } from 'react';
import type { Suggestion } from './api';
const typeConfig = {
info: { icon: '💡', color: 'blue', bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', bar: 'bg-blue-500' },
warning: { icon: '⚠️', color: 'orange', bg: 'bg-orange-50', border: 'border-orange-200', text: 'text-orange-700', bar: 'bg-orange-500' },
action: { icon: '✅', color: 'green', bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-700', bar: 'bg-green-500' },
insight: { icon: '🔍', color: 'purple', bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-700', bar: 'bg-purple-500' },
};
interface SuggestionCardProps {
suggestion: Suggestion;
onDismiss: (id: string) => void;
onAct: (id: string, actionIndex: number) => void;
}
export function SuggestionCard({ suggestion, onDismiss, onAct }: SuggestionCardProps) {
const [expanded, setExpanded] = useState(false);
const config = typeConfig[suggestion.suggestion_type] || typeConfig.info;
const confidencePercent = Math.round(suggestion.confidence * 100);
return (
<div
className={`animate-slide-in ${config.bg} ${config.border} border rounded-lg p-4 mb-3 shadow-sm hover:shadow-md transition-shadow`}
>
{/* Header */}
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-lg">{config.icon}</span>
<h3 className={`font-semibold text-sm ${config.text}`}>{suggestion.title}</h3>
</div>
<button
onClick={() => onDismiss(suggestion.id)}
className="text-gray-400 hover:text-gray-600 transition-colors"
title="Ignorieren"
>
</button>
</div>
{/* Content */}
<p className="text-sm text-gray-600 mb-3 line-clamp-3">{suggestion.content}</p>
{/* Confidence Bar */}
<div className="flex items-center gap-2 mb-3">
<div className="flex-1 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${config.bar} rounded-full transition-all duration-500`}
style={{ width: `${confidencePercent}%` }}
/>
</div>
<span className="text-xs text-gray-500 font-mono">{confidencePercent}%</span>
</div>
{/* Actions */}
{suggestion.actions && suggestion.actions.length > 0 && (
<div className="space-y-2">
{suggestion.actions.slice(0, expanded ? undefined : 2).map((action, idx) => (
<button
key={idx}
onClick={() => onAct(suggestion.id, idx)}
disabled={suggestion.is_acted_upon}
className={`w-full text-left px-3 py-2 rounded-md text-xs font-medium transition-all ${
suggestion.is_acted_upon
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white hover:bg-gray-50 text-gray-700 border border-gray-200 hover:border-gray-300'
}`}
>
<span className="font-mono text-[10px] uppercase mr-1 px-1 py-0.5 bg-gray-100 rounded">
{action.method}
</span>
{action.description}
</button>
))}
{suggestion.actions.length > 2 && (
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-gray-500 hover:text-gray-700"
>
{expanded ? 'Weniger' : `${suggestion.actions.length - 2} weitere Aktionen`}
</button>
)}
</div>
)}
{/* Acted upon badge */}
{suggestion.is_acted_upon && (
<div className="mt-2 text-xs text-green-600 font-medium flex items-center gap-1">
Ausgeführt
</div>
)}
</div>
);
}
@@ -0,0 +1,109 @@
import { useState } from 'react';
import { useSuggestions } from './api';
import { SuggestionCard } from './SuggestionCard';
interface SuggestionSidebarProps {
isOpen: boolean;
onClose: () => void;
}
export function SuggestionSidebar({ isOpen, onClose }: SuggestionSidebarProps) {
const { suggestions, connected, dismiss, act } = useSuggestions();
const [filter, setFilter] = useState<string>('all');
const filteredSuggestions = filter === 'all'
? suggestions
: suggestions.filter(s => s.suggestion_type === filter);
const filterOptions = [
{ value: 'all', label: 'Alle' },
{ value: 'info', label: 'Info' },
{ value: 'warning', label: 'Warnung' },
{ value: 'action', label: 'Aktion' },
{ value: 'insight', label: 'Erkenntnis' },
];
return (
<>
{/* Overlay */}
{isOpen && (
<div
className="fixed inset-0 bg-black/20 z-40 lg:hidden"
onClick={onClose}
/>
)}
{/* Sidebar */}
<aside
className={`fixed right-0 top-0 h-full w-96 bg-white shadow-2xl z-50 transform transition-transform duration-300 flex flex-col ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<div className="flex items-center gap-2">
<h2 className="font-semibold text-gray-800">KI Vorschläge</h2>
{connected && (
<span className="flex items-center gap-1 text-xs text-green-600">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Live
</span>
)}
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors p-1"
>
</button>
</div>
{/* Filter */}
<div className="flex gap-1 p-3 border-b border-gray-100 overflow-x-auto">
{filterOptions.map(opt => (
<button
key={opt.value}
onClick={() => setFilter(opt.value)}
className={`px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
filter === opt.value
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{opt.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{filteredSuggestions.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<div className="text-4xl mb-3">🤖</div>
<p className="text-sm">Keine Vorschläge vorhanden</p>
<p className="text-xs mt-1">Die KI analysiert deinen Kontext...</p>
</div>
) : (
<div>
{filteredSuggestions.map(suggestion => (
<SuggestionCard
n key={suggestion.id}
suggestion={suggestion}
onDismiss={dismiss}
onAct={act}
/>
))}
</div>
)}
</div>
{/* Footer */}
<div className="p-3 border-t border-gray-200 text-center">
<span className="text-xs text-gray-400">
{suggestions.length} Vorschläge insgesamt
</span>
</div>
</aside>
</>
);
}
@@ -0,0 +1,72 @@
import { useState, useEffect, useCallback } from 'react';
import { apiClient } from '@/api/client';
export interface Suggestion {
id: string;
entity_type: string;
entity_id: string | null;
suggestion_type: 'info' | 'warning' | 'action' | 'insight';
title: string;
content: string;
confidence: number;
actions: Array<{ method: string; path: string; body: any; description: string }>;
created_at: string;
is_dismissed: boolean;
is_acted_upon: boolean;
}
export function useSuggestions() {
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [connected, setConnected] = useState(false);
useEffect(() => {
// Initial load
apiClient.get('/api/v1/ai-proactive/suggestions').then(r => {
setSuggestions(r.data.items);
}).catch(() => {});
// SSE Stream
const eventSource = new EventSource('/api/v1/ai-proactive/suggestions/stream');
eventSource.onopen = () => setConnected(true);
eventSource.onerror = () => setConnected(false);
eventSource.onmessage = (e) => {
try {
const suggestion = JSON.parse(e.data);
setSuggestions(prev => [suggestion, ...prev].slice(0, 50));
} catch {}
};
return () => eventSource.close();
}, []);
const dismiss = useCallback(async (id: string) => {
setSuggestions(prev => prev.filter(s => s.id !== id));
await apiClient.post(`/api/v1/ai-proactive/suggestions/${id}/dismiss`).catch(() => {});
}, []);
const act = useCallback(async (id: string, actionIndex: number) => {
const result = await apiClient.post(`/api/v1/ai-proactive/suggestions/${id}/act`, {
action_index: actionIndex
});
setSuggestions(prev => prev.map(s => s.id === id ? { ...s, is_acted_upon: true } : s));
return result.data;
}, []);
return { suggestions, connected, dismiss, act };
}
export function useProactiveSettings() {
const [settings, setSettings] = useState<any>(null);
useEffect(() => {
apiClient.get('/api/v1/ai-proactive/settings').then(r => setSettings(r.data));
}, []);
const update = useCallback(async (updates: any) => {
const r = await apiClient.put('/api/v1/ai-proactive/settings', updates);
setSettings(r.data);
return r.data;
}, []);
return { settings, update };
}
@@ -0,0 +1,24 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { apiClient } from '@/api/client';
export function useAIContext(entityType?: string, entityId?: string, entityData?: any) {
const location = useLocation();
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
apiClient.post('/api/v1/ai-proactive/context', {
page: location.pathname,
entity_type: entityType,
entity_id: entityId,
entity_data: entityData,
}).catch(() => {}); // Silent fail, don't bother user
}, 500); // Debounce 500ms
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [location.pathname, entityType, entityId, entityData]);
}
+292
View File
@@ -0,0 +1,292 @@
"""ARQ background jobs for the AI Proactive plugin.
Deep analysis job runs after context-change for deeper analysis:
- Mail-thread summary generation
- Finding similar contacts via unified_search
- Analyzing open tasks
- Generating extended suggestion with more context
- Pushing additional suggestion if confidence > threshold
"""
from __future__ import annotations
import os
import json
import logging
import uuid
from typing import Any
import litellm
from sqlalchemy import select
from app.core.db import create_db_session
from app.core.notifications import create_notification
from app.models.audit import AuditLog
from app.models.contact import Contact
from app.plugins.builtins.ai_proactive.models import ProactiveSuggestion, ProactiveSettings
from app.plugins.builtins.ai_proactive.services import (
_serialize_row,
generate_suggestion,
get_user_settings,
push_suggestion,
)
from app.plugins.builtins.mail.models import Mail
logger = logging.getLogger(__name__)
OLLAMA_API_KEY = os.environ.get('API_KEY_OLLAMA_CLOUD', '')
DEEP_SYSTEM_PROMPT = """Du bist ein proaktiver KI-Assistent für ein CRM. Du führst eine Tiefenanalyse durch.
Analysiere den erweiterten Kontext und generiere einen detaillierten Vorschlag.
Antworte mit JSON:
{
"suggestion_type": "info|warning|action|insight",
"title": "Kurzer Titel (max 200 Zeichen)",
"content": "Detaillierte Beschreibung mit konkreten Empfehlungen",
"confidence": 0.0-1.0,
"actions": [{"method": "GET|POST|PUT|DELETE", "path": "/api/v1/...", "body": {}, "description": "..."}]
}
Fokussiere auf:
- Muster in der Kommunikationshistorie
- Beziehungen zwischen Entities
- Handlungsempfehlungen mit hoher Relevanz
- Antworte nur mit gültigem JSON"""
async def deep_analysis(
ctx: dict[str, Any],
entity_type: str,
entity_id: str,
user_id: str,
tenant_id: str,
) -> None:
"""Deep Analysis Background-Job.
Enqueued after context-change for deeper analysis:
- Mail-thread summary generation
- Similar contacts via unified_search
- Open tasks analysis
- Extended suggestion generation with more context
- If confidence > threshold: save + push additional suggestion
"""
try:
eid = uuid.UUID(entity_id)
uid = uuid.UUID(user_id)
tid = uuid.UUID(tenant_id)
except (ValueError, TypeError):
logger.warning("deep_analysis: invalid UUID parameters")
return
async with create_db_session(tid) as db:
# Get settings
settings = await get_user_settings(db, tid, uid)
if not settings.enabled:
return
# Gather extended context (more mails, more activities)
extended_context: dict[str, Any] = {
"entity_type": entity_type,
"entity_id": entity_id,
"deep_analysis": True,
}
if entity_type == "contact":
# Extended: last 30 mails
mail_result = await db.execute(
select(Mail)
.where(Mail.contact_id == eid)
.where(Mail.tenant_id == tid)
.order_by(Mail.received_at.desc())
.limit(30)
)
extended_context["mails"] = [
_serialize_row(m) for m in mail_result.scalars().all()
]
# Extended: last 50 audit entries
audit_result = await db.execute(
select(AuditLog)
.where(AuditLog.entity_id == eid)
.where(AuditLog.tenant_id == tid)
.order_by(AuditLog.timestamp.desc())
.limit(50)
)
extended_context["activities"] = [
_serialize_row(a) for a in audit_result.scalars().all()
]
# Contact data
contact_result = await db.execute(
select(Contact)
.where(Contact.id == eid)
.where(Contact.tenant_id == tid)
.limit(1)
)
contact = contact_result.scalar_one_or_none()
extended_context["contact"] = _serialize_row(contact) if contact else None
elif entity_type == "company":
from app.models.company import Company, CompanyContact
comp_result = await db.execute(
select(Company)
.where(Company.id == eid)
.where(Company.tenant_id == tid)
.limit(1)
)
company = comp_result.scalar_one_or_none()
extended_context["company"] = _serialize_row(company) if company else None
# All mails for company
mail_result = await db.execute(
select(Mail)
.where(Mail.company_id == eid)
.where(Mail.tenant_id == tid)
.order_by(Mail.received_at.desc())
.limit(30)
)
extended_context["mails"] = [
_serialize_row(m) for m in mail_result.scalars().all()
]
elif entity_type == "mail":
mail_result = await db.execute(
select(Mail)
.where(Mail.id == eid)
.where(Mail.tenant_id == tid)
.limit(1)
)
mail = mail_result.scalar_one_or_none()
extended_context["mail"] = _serialize_row(mail) if mail else None
if mail and mail.thread_id:
thread_result = await db.execute(
select(Mail)
.where(Mail.thread_id == mail.thread_id)
.where(Mail.tenant_id == tid)
.order_by(Mail.received_at.asc())
.limit(50)
)
extended_context["thread"] = [
_serialize_row(m) for m in thread_result.scalars().all()
]
# Similar entities via unified_search
try:
from app.plugins.builtins.unified_search.search_engine import (
find_similar_all_types,
)
extended_context["similar"] = await find_similar_all_types(
db, entity_type, eid, tid, limit=5
)
except Exception:
extended_context["similar"] = {}
# Generate extended suggestion with deep analysis prompt
model = settings.model or "ollama/deepseek-v4"
try:
response = await litellm.acompletion(
model=model,
messages=[
{"role": "system", "content": DEEP_SYSTEM_PROMPT},
{
"role": "user",
"content": json.dumps(
extended_context, default=str, ensure_ascii=False
),
},
],
temperature=0.3,
max_tokens=800,
response_format={"type": "json_object"},
api_key=OLLAMA_API_KEY,
)
content = response.choices[0].message.content
if not content:
return
suggestion_data = json.loads(content)
if not suggestion_data.get("title") or not suggestion_data.get("content"):
return
if not isinstance(suggestion_data.get("actions"), list):
suggestion_data["actions"] = []
confidence = suggestion_data.get("confidence", 0.5)
try:
confidence = float(confidence)
except (TypeError, ValueError):
confidence = 0.5
suggestion_data["confidence"] = max(0.0, min(1.0, confidence))
valid_types = {"info", "warning", "action", "insight"}
if suggestion_data.get("suggestion_type") not in valid_types:
suggestion_data["suggestion_type"] = "insight"
except Exception:
logger.exception("deep_analysis: LLM call failed")
return
# Confidence threshold check
if suggestion_data["confidence"] < settings.confidence_threshold:
logger.debug(
"deep_analysis: confidence %s below threshold %s",
suggestion_data["confidence"],
settings.confidence_threshold,
)
return
# Save extended suggestion
suggestion = ProactiveSuggestion(
tenant_id=tid,
user_id=uid,
entity_type=entity_type,
entity_id=eid,
suggestion_type=suggestion_data["suggestion_type"],
title=f"[Tiefenanalyse] {suggestion_data['title']}",
content=suggestion_data["content"],
confidence=suggestion_data["confidence"],
actions=suggestion_data["actions"],
context_snapshot=extended_context,
)
db.add(suggestion)
await db.flush()
suggestion_dict = {
"id": str(suggestion.id),
"entity_type": suggestion.entity_type,
"entity_id": str(suggestion.entity_id) if suggestion.entity_id else None,
"suggestion_type": suggestion.suggestion_type,
"title": suggestion.title,
"content": suggestion.content,
"confidence": suggestion.confidence,
"actions": suggestion.actions,
"created_at": suggestion.created_at.isoformat()
if suggestion.created_at
else None,
"is_dismissed": suggestion.is_dismissed,
"is_acted_upon": suggestion.is_acted_upon,
}
# Push via SSE
await push_suggestion(str(uid), suggestion_dict)
# Notification for urgent suggestions
if suggestion.suggestion_type == "warning":
try:
await create_notification(
db,
tid,
uid,
type="ai_suggestion_urgent",
title=suggestion.title,
body=suggestion.content[:200],
)
except Exception:
logger.exception("deep_analysis: notification failed")
await db.commit()
logger.info(
"deep_analysis: generated suggestion %s for %s/%s",
suggestion.id,
entity_type,
entity_id,
)
@@ -0,0 +1,50 @@
-- ai_proactive plugin: suggestions, context log, user settings
-- Suggestions Store
CREATE TABLE IF NOT EXISTS ai_proactive_suggestions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id UUID,
suggestion_type VARCHAR(50) NOT NULL DEFAULT 'info', -- info, warning, action, insight
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
confidence FLOAT NOT NULL DEFAULT 0.5,
actions JSONB NOT NULL DEFAULT '[]', -- [{method, path, body, description}]
is_dismissed BOOLEAN NOT NULL DEFAULT FALSE,
is_acted_upon BOOLEAN NOT NULL DEFAULT FALSE,
context_snapshot JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS ix_suggestions_user ON ai_proactive_suggestions (tenant_id, user_id, is_dismissed, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_suggestions_entity ON ai_proactive_suggestions (tenant_id, entity_type, entity_id);
-- Context Log (was der User wann angesehen hat)
CREATE TABLE IF NOT EXISTS ai_proactive_context_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
page VARCHAR(200) NOT NULL,
entity_type VARCHAR(50),
entity_id UUID,
entity_data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS ix_context_log_user_time ON ai_proactive_context_log (tenant_id, user_id, created_at DESC);
-- User Settings für proactive AI
CREATE TABLE IF NOT EXISTS ai_proactive_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
suggestion_categories JSONB NOT NULL DEFAULT '["mail","tasks","contacts","companies","insights"]',
confidence_threshold FLOAT NOT NULL DEFAULT 0.5,
rate_limit_seconds INT NOT NULL DEFAULT 10,
model VARCHAR(100) NOT NULL DEFAULT 'gpt-4o-mini',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, user_id)
);
+111
View File
@@ -0,0 +1,111 @@
"""SQLAlchemy models for the AI Proactive plugin."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class ProactiveSuggestion(Base, TenantMixin):
"""A proactive AI suggestion generated from user context."""
__tablename__ = "ai_proactive_suggestions"
__table_args__ = (
Index(
"ix_suggestions_user",
"tenant_id",
"user_id",
"is_dismissed",
"created_at",
),
Index("ix_suggestions_entity", "tenant_id", "entity_type", "entity_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
entity_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
suggestion_type: Mapped[str] = mapped_column(
String(50), nullable=False, default="info"
)
title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
actions: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
is_dismissed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_acted_upon: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
context_snapshot: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, default=dict
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
class ContextLog(Base, TenantMixin):
"""Log of user context changes (page views, entity selections)."""
__tablename__ = "ai_proactive_context_log"
__table_args__ = (
Index("ix_context_log_user_time", "tenant_id", "user_id", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
page: Mapped[str] = mapped_column(String(200), nullable=False)
entity_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
entity_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
entity_data: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, default=dict
)
class ProactiveSettings(Base, TenantMixin):
"""Per-user settings for proactive AI."""
__tablename__ = "ai_proactive_settings"
__table_args__ = (
Index("ix_proactive_settings_user", "tenant_id", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
suggestion_categories: Mapped[list[Any]] = mapped_column(
JSONB, nullable=False, default=lambda: ["mail", "tasks", "contacts", "companies", "insights"]
)
confidence_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.5
)
rate_limit_seconds: Mapped[int] = mapped_column(
Integer, nullable=False, default=10
)
model: Mapped[str] = mapped_column(String(100), nullable=False, default="ollama/deepseek-v4")
+107
View File
@@ -0,0 +1,107 @@
"""Proactive AI Agent plugin — context-aware suggestions built on
unified_search and ai_assistant.
Listens to context-change events, gathers entity data, generates LLM-powered
suggestions, pushes them via SSE, and registers AI tools for the assistant.
"""
from __future__ import annotations
import logging
from typing import Any
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
logger = logging.getLogger(__name__)
class AIProactivePlugin(BasePlugin):
"""Proactive KI Agent that monitors user context and suggests actions."""
manifest = PluginManifest(
name="ai_proactive",
version="1.0.0",
display_name="Proaktiver KI Agent",
description=(
"Überwacht den User-Kontext, generiert proaktiv Vorschläge per LLM "
"und pusht diese via SSE. Nutzt unified_search und ai_assistant."
),
dependencies=["ai_assistant", "unified_search"],
routes=[
PluginRouteDef(
path="/api/v1/ai-proactive",
module="app.plugins.builtins.ai_proactive.routes",
router_attr="router",
),
],
events=["context.view_changed", "context.entity_selected"],
migrations=["0001_initial.sql"],
permissions=[
"ai_proactive:read",
"ai_proactive:write",
"ai_proactive:config",
],
is_core=False,
)
async def on_activate(self, db, service_container, event_bus) -> None:
"""Register context tools and subscribe to events."""
await super().on_activate(db, service_container, event_bus)
try:
from app.plugins.builtins.ai_proactive.context_tools import (
register_context_tools,
)
from app.plugins.builtins.ai_assistant.tool_registry import (
get_tool_registry,
)
register_context_tools(get_tool_registry())
logger.info("AI Proactive context tools registered")
except Exception:
logger.exception("Failed to register AI Proactive context tools")
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Unregister tools and event listeners."""
try:
from app.plugins.builtins.ai_assistant.tool_registry import (
get_tool_registry,
)
get_tool_registry().unregister_plugin("ai_proactive")
logger.info("AI Proactive context tools unregistered")
except Exception:
logger.exception("Failed to unregister AI Proactive context tools")
await super().on_deactivate(db, service_container, event_bus)
# ─── Event Handlers ───
async def on_context_view_changed(self, payload: dict[str, Any]) -> None:
"""Handle context.view_changed event."""
from app.plugins.builtins.ai_proactive.services import handle_context_change
await handle_context_change(payload)
async def on_context_entity_selected(self, payload: dict[str, Any]) -> None:
"""Handle context.entity_selected event."""
from app.plugins.builtins.ai_proactive.services import handle_context_change
await handle_context_change(payload)
def get_notification_types(self) -> list[dict[str, Any]]:
return [
{
"type_key": "ai_suggestion",
"category": "ai",
"label": "KI Vorschlag",
"description": "Proaktiver KI-Vorschlag",
"is_enabled_by_default": True,
},
{
"type_key": "ai_suggestion_urgent",
"category": "ai",
"label": "Dringender KI Vorschlag",
"description": "Dringender proaktiver KI-Vorschlag",
"is_enabled_by_default": True,
},
]
+317
View File
@@ -0,0 +1,317 @@
"""API routes for the AI Proactive plugin.
Endpoints: context tracking, suggestions CRUD, SSE stream, settings, stats.
"""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db, set_tenant_context
from app.core.event_bus import get_event_bus
from app.deps import get_current_user, require_permission
from app.plugins.builtins.ai_proactive.models import (
ContextLog,
ProactiveSettings,
ProactiveSuggestion,
)
from app.plugins.builtins.ai_proactive.schemas import (
ActRequest,
ActResponse,
ContextReport,
SettingsResponse,
SettingsUpdate,
SuggestionListResponse,
SuggestionResponse,
StatsResponse,
)
from app.plugins.builtins.ai_proactive.services import (
execute_suggested_action,
get_active_suggestions,
get_sse_queue,
get_stats,
get_user_settings,
mark_dismissed,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/ai-proactive", tags=["ai-proactive"])
def _suggestion_to_response(s: ProactiveSuggestion) -> SuggestionResponse:
"""Convert a ProactiveSuggestion model to SuggestionResponse."""
return SuggestionResponse(
id=str(s.id),
entity_type=s.entity_type,
entity_id=str(s.entity_id) if s.entity_id else None,
suggestion_type=s.suggestion_type,
title=s.title,
content=s.content,
confidence=s.confidence,
actions=s.actions or [],
created_at=s.created_at,
is_dismissed=s.is_dismissed,
is_acted_upon=s.is_acted_upon,
)
def _settings_to_response(s: ProactiveSettings) -> SettingsResponse:
"""Convert ProactiveSettings model to SettingsResponse."""
return SettingsResponse(
enabled=s.enabled,
suggestion_categories=s.suggestion_categories or [],
confidence_threshold=s.confidence_threshold,
rate_limit_seconds=s.rate_limit_seconds,
model=s.model,
)
# ─── Context Tracking ───
@router.post("/context", dependencies=[Depends(require_permission("ai_proactive:read"))])
async def report_context(
context: ContextReport,
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Frontend reports current context (page, entity).
Stores context log entry and publishes event for proactive engine.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Parse entity_id if present
entity_id: uuid.UUID | None = None
if context.entity_id:
try:
entity_id = uuid.UUID(context.entity_id)
except (ValueError, TypeError):
entity_id = None
# Store context log
log_entry = ContextLog(
tenant_id=tenant_id,
user_id=user_id,
page=context.page,
entity_type=context.entity_type,
entity_id=entity_id,
entity_data=context.entity_data or {},
)
db.add(log_entry)
await db.flush()
# Publish event
event_bus = get_event_bus()
event_name = (
"context.entity_selected"
if context.entity_type and context.entity_id
else "context.view_changed"
)
payload = {
"user_id": str(user_id),
"tenant_id": str(tenant_id),
"page": context.page,
"entity_type": context.entity_type,
"entity_id": str(entity_id) if entity_id else None,
"entity_data": context.entity_data or {},
}
await event_bus.publish(event_name, payload)
return {"status": "ok"}
# ─── Suggestions ───
@router.get(
"/suggestions",
dependencies=[Depends(require_permission("ai_proactive:read"))],
response_model=SuggestionListResponse,
)
async def get_suggestions(
entity_type: str | None = Query(None),
entity_id: str | None = Query(None),
limit: int = Query(10, ge=1, le=100),
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get active suggestions for the current user."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
eid: uuid.UUID | None = None
if entity_id:
try:
eid = uuid.UUID(entity_id)
except (ValueError, TypeError):
eid = None
suggestions = await get_active_suggestions(
db, tenant_id, user_id, entity_type=entity_type, entity_id=eid, limit=limit
)
items = [_suggestion_to_response(s) for s in suggestions]
return SuggestionListResponse(items=items, total=len(items))
@router.post(
"/suggestions/{suggestion_id}/dismiss",
dependencies=[Depends(require_permission("ai_proactive:write"))],
)
async def dismiss_suggestion(
suggestion_id: str,
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Mark a suggestion as dismissed."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
sid = uuid.UUID(suggestion_id)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid suggestion ID")
success = await mark_dismissed(db, sid, user_id, tenant_id)
if not success:
raise HTTPException(status_code=404, detail="Suggestion not found")
return {"status": "ok"}
@router.post(
"/suggestions/{suggestion_id}/act",
dependencies=[Depends(require_permission("ai_proactive:write"))],
response_model=ActResponse,
)
async def act_on_suggestion(
suggestion_id: str,
action: ActRequest,
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Execute a suggested action."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
sid = uuid.UUID(suggestion_id)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid suggestion ID")
result = await execute_suggested_action(
db, sid, action.action_index, user_id, tenant_id, current_user
)
return ActResponse(
success=result.get("success", False),
data=result.get("data"),
error=result.get("error"),
)
# ─── SSE Stream ───
@router.get(
"/suggestions/stream",
dependencies=[Depends(require_permission("ai_proactive:read"))],
)
async def stream_suggestions(
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""SSE stream: push new suggestions in real-time.
Uses an asyncio.Queue per user. Heartbeat every 30s.
"""
user_id = str(current_user["user_id"])
async def event_generator():
queue = get_sse_queue(user_id)
while True:
try:
suggestion = await asyncio.wait_for(queue.get(), timeout=30)
yield f"data: {json.dumps(suggestion, default=str)}\n\n"
except asyncio.TimeoutError:
yield ": keepalive\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# ─── Settings ───
@router.get(
"/settings",
dependencies=[Depends(require_permission("ai_proactive:read"))],
response_model=SettingsResponse,
)
async def get_settings(
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get proactive AI settings for the current user."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
settings = await get_user_settings(db, tenant_id, user_id)
return _settings_to_response(settings)
@router.put(
"/settings",
dependencies=[Depends(require_permission("ai_proactive:config"))],
response_model=SettingsResponse,
)
async def update_settings(
settings_update: SettingsUpdate,
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update proactive AI settings for the current user."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
settings = await get_user_settings(db, tenant_id, user_id)
update_data = settings_update.model_dump(exclude_unset=True)
for field, val in update_data.items():
setattr(settings, field, val)
await db.flush()
return _settings_to_response(settings)
# ─── Stats ───
@router.get(
"/stats",
dependencies=[Depends(require_permission("ai_proactive:read"))],
response_model=StatsResponse,
)
async def get_stats_endpoint(
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get proactive AI usage statistics for the current user."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
stats = await get_stats(db, tenant_id, user_id)
return StatsResponse(**stats)
@@ -0,0 +1,103 @@
"""Pydantic v2 schemas for the AI Proactive plugin API."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class ContextReport(BaseModel):
"""Frontend reports the current page/entity context."""
page: str = Field(..., description="Current page path")
entity_type: str | None = Field(None, description="Entity type being viewed")
entity_id: str | None = Field(None, description="Entity ID being viewed")
entity_data: dict[str, Any] | None = Field(
None, description="Additional entity metadata"
)
class SuggestionAction(BaseModel):
"""A suggested CRM action."""
method: str = Field(..., description="HTTP method")
path: str = Field(..., description="API path")
body: dict[str, Any] | None = Field(None, description="Request body")
description: str = Field(..., description="Human-readable description")
class SuggestionResponse(BaseModel):
"""API response for a single suggestion."""
id: str
entity_type: str
entity_id: str | None
suggestion_type: str
title: str
content: str
confidence: float
actions: list[dict[str, Any]]
created_at: datetime
is_dismissed: bool
is_acted_upon: bool = False
class SuggestionListResponse(BaseModel):
"""Paginated list of suggestions."""
items: list[SuggestionResponse]
total: int
class ActRequest(BaseModel):
"""Execute a suggested action by index."""
action_index: int = Field(..., ge=0, description="Index into actions array")
class ActResponse(BaseModel):
"""Result of executing a suggested action."""
success: bool
data: dict[str, Any] | None = None
error: str | None = None
class SettingsResponse(BaseModel):
"""Proactive AI settings for the current user."""
enabled: bool
suggestion_categories: list[str]
confidence_threshold: float
rate_limit_seconds: int
model: str
available_models: list[str] = Field(default_factory=lambda: [
'ollama/deepseek-v4',
'ollama/deepseek-v4-pro',
'ollama/llama3.2',
'ollama/gpt-4o-mini',
'gpt-4o-mini',
])
class SettingsUpdate(BaseModel):
"""Partial update for proactive AI settings."""
enabled: bool | None = None
suggestion_categories: list[str] | None = None
confidence_threshold: float | None = None
rate_limit_seconds: int | None = None
model: str | None = None
class StatsResponse(BaseModel):
"""Proactive AI usage statistics."""
total_suggestions: int = 0
dismissed: int = 0
acted_upon: int = 0
active: int = 0
dismiss_rate: float = 0.0
act_rate: float = 0.0
@@ -0,0 +1,748 @@
"""Proactive Engine — core logic for context-aware AI suggestions.
Handles context changes, gathers entity data, generates LLM-powered
suggestions, pushes via SSE, and manages suggestion lifecycle.
"""
from __future__ import annotations
import os
import asyncio
import json
import logging
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
import litellm
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.cache import get_cache
from app.core.db import create_db_session, get_session_factory
from app.core.notifications import create_notification
from app.models.audit import AuditLog
from app.models.company import Company
from app.models.contact import CompanyContact, Contact
from app.plugins.builtins.ai_proactive.models import (
ContextLog,
ProactiveSettings,
ProactiveSuggestion,
)
logger = logging.getLogger(__name__)
litellm.suppress_debug_info = True
OLLAMA_API_KEY = os.environ.get('API_KEY_OLLAMA_CLOUD', '')
# ─── SSE Push Infrastructure ───
_sse_queues: dict[str, asyncio.Queue[dict[str, Any]]] = {}
def get_sse_queue(user_id: str) -> asyncio.Queue[dict[str, Any]]:
"""Get or create SSE queue for a user."""
if user_id not in _sse_queues:
_sse_queues[user_id] = asyncio.Queue()
return _sse_queues[user_id]
async def push_suggestion(user_id: str, suggestion: dict[str, Any]) -> None:
"""Push suggestion to user's SSE queue."""
queue = get_sse_queue(user_id)
await queue.put(suggestion)
# ─── Rate Limiting ───
async def is_rate_limited(
tenant_id: uuid.UUID, user_id: uuid.UUID, rate_limit_seconds: int
) -> bool:
"""Check if user is rate-limited using Redis.
Returns True if rate-limited (key exists), False otherwise.
Sets a key with TTL = rate_limit_seconds on first call.
"""
try:
r = get_cache()
key = f"ai_proactive:rate:{user_id}"
existing = await r.get(key)
if existing is not None:
return True
await r.setex(key, rate_limit_seconds, "1")
return False
except Exception:
logger.exception("Rate-limit check failed, allowing request")
return False
# ─── Settings Helpers ───
async def get_user_settings(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID
) -> ProactiveSettings:
"""Get proactive AI settings for user, create defaults if not exist."""
result = await db.execute(
select(ProactiveSettings)
.where(ProactiveSettings.tenant_id == tenant_id)
.where(ProactiveSettings.user_id == user_id)
.limit(1)
)
settings = result.scalar_one_or_none()
if settings is None:
settings = ProactiveSettings(
tenant_id=tenant_id,
user_id=user_id, enabled=True,
suggestion_categories=["mail", "tasks", "contacts", "companies", "insights"],
confidence_threshold=0.5,
rate_limit_seconds=10,
model="ollama/deepseek-v4",
)
db.add(settings)
await db.flush()
return settings
# ─── Context Gathering ───
def _serialize_row(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 isinstance(val, datetime):
result[column.name] = val.isoformat()
elif isinstance(val, uuid.UUID):
result[column.name] = str(val)
else:
result[column.name] = val
return result
async def gather_context(
db: AsyncSession, entity_type: str, entity_id: uuid.UUID, tenant_id: uuid.UUID
) -> dict[str, Any]:
"""Collect context data for an entity.
Gathers related data from contacts, companies, mails, calendar events,
audit logs, and semantically similar entities via unified_search.
"""
context: dict[str, Any] = {
"entity_type": entity_type,
"entity_id": str(entity_id),
}
if entity_type == "contact":
# Contact data
result = await db.execute(
select(Contact)
.where(Contact.id == entity_id)
.where(Contact.tenant_id == tenant_id)
.limit(1)
)
contact = result.scalar_one_or_none()
context["contact"] = _serialize_row(contact) if contact else None
# Last 10 mails
from app.plugins.builtins.mail.models import Mail
mail_result = await db.execute(
select(Mail)
.where(Mail.contact_id == entity_id)
.where(Mail.tenant_id == tenant_id)
.order_by(Mail.received_at.desc())
.limit(10)
)
context["mails"] = [_serialize_row(m) for m in mail_result.scalars().all()]
# Company via company_contacts
cc_result = await db.execute(
select(CompanyContact)
.where(CompanyContact.contact_id == entity_id)
.where(CompanyContact.tenant_id == tenant_id)
.limit(5)
)
companies: list[dict[str, Any]] = []
for cc in cc_result.scalars().all():
comp_result = await db.execute(
select(Company)
.where(Company.id == cc.company_id)
.where(Company.tenant_id == tenant_id)
.limit(1)
)
comp = comp_result.scalar_one_or_none()
if comp:
comp_data = _serialize_row(comp)
comp_data["role_at_company"] = cc.role_at_company
comp_data["is_primary"] = cc.is_primary
companies.append(comp_data)
context["company"] = companies[0] if companies else None
context["companies"] = companies
# Upcoming calendar events
from app.plugins.builtins.calendar.models import CalendarEntry, CalendarEntryLink
now = datetime.now(UTC)
event_result = await db.execute(
select(CalendarEntry)
.join(CalendarEntryLink, CalendarEntryLink.entry_id == CalendarEntry.id)
.where(CalendarEntryLink.entity_type == "contact")
.where(CalendarEntryLink.entity_id == entity_id)
.where(CalendarEntry.tenant_id == tenant_id)
.where(CalendarEntry.start_at > now)
.order_by(CalendarEntry.start_at.asc())
.limit(5)
)
context["events"] = [_serialize_row(e) for e in event_result.scalars().all()]
# Last 20 audit log entries
audit_result = await db.execute(
select(AuditLog)
.where(AuditLog.entity_id == entity_id)
.where(AuditLog.tenant_id == tenant_id)
.order_by(AuditLog.timestamp.desc())
.limit(20)
)
context["activities"] = [_serialize_row(a) for a in audit_result.scalars().all()]
elif entity_type == "mail":
from app.plugins.builtins.mail.models import Mail
result = await db.execute(
select(Mail)
.where(Mail.id == entity_id)
.where(Mail.tenant_id == tenant_id)
.limit(1)
)
mail = result.scalar_one_or_none()
context["mail"] = _serialize_row(mail) if mail else None
if mail and mail.contact_id:
contact_result = await db.execute(
select(Contact)
.where(Contact.id == mail.contact_id)
.where(Contact.tenant_id == tenant_id)
.limit(1)
)
contact = contact_result.scalar_one_or_none()
context["contact"] = _serialize_row(contact) if contact else None
if mail and mail.thread_id:
thread_result = await db.execute(
select(Mail)
.where(Mail.thread_id == mail.thread_id)
.where(Mail.tenant_id == tenant_id)
.order_by(Mail.received_at.asc())
.limit(20)
)
context["thread"] = [_serialize_row(m) for m in thread_result.scalars().all()]
if mail and mail.company_id:
comp_result = await db.execute(
select(Company)
.where(Company.id == mail.company_id)
.where(Company.tenant_id == tenant_id)
.limit(1)
)
comp = comp_result.scalar_one_or_none()
context["company"] = _serialize_row(comp) if comp else None
elif entity_type == "company":
result = await db.execute(
select(Company)
.where(Company.id == entity_id)
.where(Company.tenant_id == tenant_id)
.limit(1)
)
company = result.scalar_one_or_none()
context["company"] = _serialize_row(company) if company else None
# Contacts via company_contacts
cc_result = await db.execute(
select(CompanyContact)
.where(CompanyContact.company_id == entity_id)
.where(CompanyContact.tenant_id == tenant_id)
)
contacts: list[dict[str, Any]] = []
for cc in cc_result.scalars().all():
contact_result = await db.execute(
select(Contact)
.where(Contact.id == cc.contact_id)
.where(Contact.tenant_id == tenant_id)
.limit(1)
)
contact = contact_result.scalar_one_or_none()
if contact:
contact_data = _serialize_row(contact)
contact_data["role_at_company"] = cc.role_at_company
contact_data["is_primary"] = cc.is_primary
contacts.append(contact_data)
context["contacts"] = contacts
# Mails for this company
from app.plugins.builtins.mail.models import Mail
mail_result = await db.execute(
select(Mail)
.where(Mail.company_id == entity_id)
.where(Mail.tenant_id == tenant_id)
.order_by(Mail.received_at.desc())
.limit(10)
)
context["mails"] = [_serialize_row(m) for m in mail_result.scalars().all()]
# Upcoming events
from app.plugins.builtins.calendar.models import CalendarEntry, CalendarEntryLink
now = datetime.now(UTC)
event_result = await db.execute(
select(CalendarEntry)
.join(CalendarEntryLink, CalendarEntryLink.entry_id == CalendarEntry.id)
.where(CalendarEntryLink.entity_type == "company")
.where(CalendarEntryLink.entity_id == entity_id)
.where(CalendarEntry.tenant_id == tenant_id)
.where(CalendarEntry.start_at > now)
.order_by(CalendarEntry.start_at.asc())
.limit(5)
)
context["events"] = [_serialize_row(e) for e in event_result.scalars().all()]
elif entity_type == "file":
# Basic file info via raw SQL (file model may vary)
try:
file_result = await db.execute(
text("SELECT * FROM files WHERE id = :fid AND tenant_id = :tid"),
{"fid": entity_id, "tid": tenant_id},
)
file_row = file_result.mappings().first()
context["file"] = dict(file_row) if file_row else None
except Exception:
context["file"] = None
# Linked entities via entity_links (if table exists)
try:
links_result = await db.execute(
text(
"SELECT * FROM entity_links WHERE entity_id = :eid AND tenant_id = :tid LIMIT 20"
),
{"eid": entity_id, "tid": tenant_id},
)
context["linked_entities"] = [dict(r) for r in links_result.mappings().all()]
except Exception:
context["linked_entities"] = []
# Semantically similar entities via unified_search
try:
from app.plugins.builtins.unified_search.search_engine import (
find_similar_all_types,
)
context["similar"] = await find_similar_all_types(
db, entity_type, entity_id, tenant_id, limit=3
)
except Exception:
context["similar"] = {}
return context
# ─── Suggestion Generation ───
SYSTEM_PROMPT = """Du bist ein proaktiver KI-Assistent für ein CRM. Analysiere den Kontext und generiere Vorschläge.
Antworte mit JSON:
{
"suggestion_type": "info|warning|action|insight",
"title": "Kurzer Titel (max 200 Zeichen)",
"content": "Beschreibung des Vorschlags",
"confidence": 0.0-1.0,
"actions": [{"method": "GET|POST|PUT|DELETE", "path": "/api/v1/...", "body": {}, "description": "..."}]
}
- suggestion_type: info=Information, warning=Warnung, action=Aktionsvorschlag, insight=Erkenntnis
- actions: Vorgeschlagene CRM-Aktionen die der User ausführen kann
- confidence: Wie sicher bist du dir (0.0=unsicher, 1.0=sehr sicher)
- Antworte nur mit gültigem JSON, kein Markdown"""
async def generate_suggestion(
context_data: dict[str, Any], settings: ProactiveSettings
) -> dict[str, Any] | None:
"""LLM generates a suggestion from context data.
Returns dict with suggestion_type, title, content, confidence, actions
or None on failure.
"""
model = settings.model or "ollama/deepseek-v4"
try:
response = await litellm.acompletion(
model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": json.dumps(context_data, default=str, ensure_ascii=False),
},
],
temperature=0.3,
max_tokens=500,
response_format={"type": "json_object"},
api_key=OLLAMA_API_KEY,
)
content = response.choices[0].message.content
if not content:
return None
result = json.loads(content)
# Validate required fields
if not result.get("title") or not result.get("content"):
return None
# Ensure actions is a list
if not isinstance(result.get("actions"), list):
result["actions"] = []
# Clamp confidence
confidence = result.get("confidence", 0.5)
try:
confidence = float(confidence)
except (TypeError, ValueError):
confidence = 0.5
result["confidence"] = max(0.0, min(1.0, confidence))
# Validate suggestion_type
valid_types = {"info", "warning", "action", "insight"}
if result.get("suggestion_type") not in valid_types:
result["suggestion_type"] = "info"
return result
except Exception:
logger.exception("Failed to generate suggestion via LLM")
return None
# ─── Main Handler ───
async def handle_context_change(payload: dict[str, Any]) -> None:
"""Main handler: Context-Change → Suggestion generation.
1. Rate-limit check
2. Settings check (enabled?)
3. Gather context data
4. Generate suggestion via LLM
5. Confidence threshold check
6. Save suggestion to DB
7. Push via SSE
8. Notification for urgent suggestions
9. Enqueue background deep analysis job
"""
user_id_str = payload.get("user_id")
tenant_id_str = payload.get("tenant_id")
entity_type = payload.get("entity_type")
entity_id_str = payload.get("entity_id")
if not user_id_str or not tenant_id_str or not entity_type:
logger.warning("handle_context_change: missing required fields in payload")
return
try:
tenant_id = uuid.UUID(tenant_id_str)
user_id = uuid.UUID(user_id_str)
except (ValueError, TypeError):
logger.warning("handle_context_change: invalid UUID in payload")
return
entity_id: uuid.UUID | None = None
if entity_id_str:
try:
entity_id = uuid.UUID(entity_id_str)
except (ValueError, TypeError):
entity_id = None
if entity_id is None:
logger.debug("handle_context_change: no entity_id, skipping")
return
async with create_db_session(tenant_id) as db:
# Get settings
settings = await get_user_settings(db, tenant_id, user_id)
if not settings.enabled:
logger.debug("handle_context_change: proactive AI disabled for user")
return
# Rate limit check
if await is_rate_limited(tenant_id, user_id, settings.rate_limit_seconds):
logger.debug("handle_context_change: rate limited")
return
# Gather context
context_data = await gather_context(db, entity_type, entity_id, tenant_id)
# Generate suggestion
suggestion_data = await generate_suggestion(context_data, settings)
if suggestion_data is None:
logger.debug("handle_context_change: no suggestion generated")
return
# Confidence threshold check
if suggestion_data["confidence"] < settings.confidence_threshold:
logger.debug(
"handle_context_change: confidence %s below threshold %s",
suggestion_data["confidence"],
settings.confidence_threshold,
)
return
# Save suggestion
suggestion = ProactiveSuggestion(
tenant_id=tenant_id,
user_id=user_id,
entity_type=entity_type,
entity_id=entity_id,
suggestion_type=suggestion_data["suggestion_type"],
title=suggestion_data["title"],
content=suggestion_data["content"],
confidence=suggestion_data["confidence"],
actions=suggestion_data["actions"],
context_snapshot=context_data,
)
db.add(suggestion)
await db.flush()
# Build response dict for SSE
suggestion_dict = {
"id": str(suggestion.id),
"entity_type": suggestion.entity_type,
"entity_id": str(suggestion.entity_id) if suggestion.entity_id else None,
"suggestion_type": suggestion.suggestion_type,
"title": suggestion.title,
"content": suggestion.content,
"confidence": suggestion.confidence,
"actions": suggestion.actions,
"created_at": suggestion.created_at.isoformat() if suggestion.created_at else None,
"is_dismissed": suggestion.is_dismissed,
"is_acted_upon": suggestion.is_acted_upon,
}
# Push via SSE
await push_suggestion(str(user_id), suggestion_dict)
# Notification for urgent suggestions
if suggestion.suggestion_type == "warning":
try:
await create_notification(
db,
tenant_id,
user_id,
type="ai_suggestion_urgent",
title=suggestion.title,
body=suggestion.content[:200],
)
except Exception:
logger.exception("Failed to create notification")
else:
try:
await create_notification(
db,
tenant_id,
user_id,
type="ai_suggestion",
title=suggestion.title,
body=suggestion.content[:200],
)
except Exception:
logger.exception("Failed to create notification")
await db.commit()
# Enqueue background deep analysis job
try:
from app.core.jobs import enqueue_job
await enqueue_job(
"deep_analysis",
entity_type,
str(entity_id),
str(user_id),
str(tenant_id),
)
except Exception:
logger.exception("Failed to enqueue deep_analysis job")
# ─── Suggestion CRUD ───
async def get_active_suggestions(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
entity_type: str | None = None,
entity_id: uuid.UUID | None = None,
limit: int = 10,
) -> list[ProactiveSuggestion]:
"""Get active (non-dismissed, not expired) suggestions for user."""
now = datetime.now(UTC)
stmt = (
select(ProactiveSuggestion)
.where(ProactiveSuggestion.tenant_id == tenant_id)
.where(ProactiveSuggestion.user_id == user_id)
.where(ProactiveSuggestion.is_dismissed == False) # noqa: E712
.where(
(ProactiveSuggestion.expires_at.is_(None))
| (ProactiveSuggestion.expires_at > now)
)
)
if entity_type:
stmt = stmt.where(ProactiveSuggestion.entity_type == entity_type)
if entity_id:
stmt = stmt.where(ProactiveSuggestion.entity_id == entity_id)
stmt = stmt.order_by(ProactiveSuggestion.created_at.desc()).limit(limit)
result = await db.execute(stmt)
return list(result.scalars().all())
async def mark_dismissed(
db: AsyncSession,
suggestion_id: uuid.UUID,
user_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> bool:
"""Mark suggestion as dismissed. Returns True if found and updated."""
result = await db.execute(
select(ProactiveSuggestion)
.where(ProactiveSuggestion.id == suggestion_id)
.where(ProactiveSuggestion.tenant_id == tenant_id)
.where(ProactiveSuggestion.user_id == user_id)
.limit(1)
)
suggestion = result.scalar_one_or_none()
if suggestion is None:
return False
suggestion.is_dismissed = True
await db.flush()
return True
async def execute_suggested_action(
db: AsyncSession,
suggestion_id: uuid.UUID,
action_index: int,
user_id: uuid.UUID,
tenant_id: uuid.UUID,
user_context: dict[str, Any],
) -> dict[str, Any]:
"""Execute a suggested action.
1. Load suggestion
2. Get action from actions[action_index]
3. Execute via internal HTTP call (httpx)
4. Mark suggestion as is_acted_upon=True
5. Return result
"""
import httpx
result = await db.execute(
select(ProactiveSuggestion)
.where(ProactiveSuggestion.id == suggestion_id)
.where(ProactiveSuggestion.tenant_id == tenant_id)
.where(ProactiveSuggestion.user_id == user_id)
.limit(1)
)
suggestion = result.scalar_one_or_none()
if suggestion is None:
return {"success": False, "error": "Suggestion not found"}
actions = suggestion.actions or []
if action_index < 0 or action_index >= len(actions):
return {"success": False, "error": "Invalid action index"}
action = actions[action_index]
method = action.get("method", "GET").upper()
path = action.get("path", "")
body = action.get("body")
if not path:
return {"success": False, "error": "No path in action"}
# Build internal URL
from app.config import get_settings
settings = get_settings()
base_url = getattr(settings, "internal_base_url", "http://localhost:8000")
url = f"{base_url}{path}"
# Build headers from user context (session cookie)
headers: dict[str, str] = {"Content-Type": "application/json"}
cookie_name = getattr(settings, "session_cookie_name", "session")
session_id = user_context.get("session_id", "")
if session_id:
headers["Cookie"] = f"{cookie_name}={session_id}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.request(
method,
url,
json=body if body else None,
headers=headers,
)
data = None
try:
data = resp.json()
except Exception:
data = {"status_code": resp.status_code, "text": resp.text}
if resp.status_code < 400:
suggestion.is_acted_upon = True
await db.flush()
return {"success": True, "data": data}
return {"success": False, "error": f"HTTP {resp.status_code}", "data": data}
except Exception as e:
logger.exception("Failed to execute suggested action")
return {"success": False, "error": str(e)}
# ─── Stats ───
async def get_stats(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID
) -> dict[str, Any]:
"""Get proactive AI usage statistics for a user."""
base_filter = (
ProactiveSuggestion.tenant_id == tenant_id,
ProactiveSuggestion.user_id == user_id,
)
total_result = await db.execute(
select(func.count()).select_from(ProactiveSuggestion).where(*base_filter)
)
total = total_result.scalar() or 0
dismissed_result = await db.execute(
select(func.count())
.select_from(ProactiveSuggestion)
.where(*base_filter, ProactiveSuggestion.is_dismissed == True) # noqa: E712
)
dismissed = dismissed_result.scalar() or 0
acted_result = await db.execute(
select(func.count())
.select_from(ProactiveSuggestion)
.where(*base_filter, ProactiveSuggestion.is_acted_upon == True) # noqa: E712
)
acted_upon = acted_result.scalar() or 0
active = total - dismissed
dismiss_rate = (dismissed / total) if total > 0 else 0.0
act_rate = (acted_upon / total) if total > 0 else 0.0
return {
"total_suggestions": total,
"dismissed": dismissed,
"acted_upon": acted_upon,
"active": active,
"dismiss_rate": round(dismiss_rate, 4),
"act_rate": round(act_rate, 4),
}