Files
leocrm/app/plugins/builtins/ai_proactive/routes.py
T
Agent Zero 8cebb4f4e9 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
2026-07-18 11:21:51 +02:00

318 lines
9.2 KiB
Python

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