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,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)
|
||||
Reference in New Issue
Block a user