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:
@@ -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]);
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user