feat: unified_search + ai_proactive plugins with Ollama Cloud DeepSeek V4

- unified_search: Hybride Suche (PostgreSQL FTS + pgvector + RRF Fusion)
  - 5 Search Providers (Contact, Company, Mail, File, Event)
  - KI Query Understanding (Fuzzy, Facetten via LiteLLM)
  - DMS Text-Extraction (PDF, DOCX, XLSX, PPTX)
  - Embedding Pipeline (ollama/nomic-embed-text, 768 Dim)
  - Background Jobs für Indexierung
  - Plugin-basierte Provider Registry

- ai_proactive: Proaktiver KI-Agent
  - Context-Tracking (Frontend → Backend → Event Bus)
  - Proactive Engine mit LLM Suggestion-Generierung
  - SSE Real-time Push an Frontend
  - 6 AI Tools für Tool Registry
  - Rate-Limiting + User Settings
  - Deep Analysis Background Jobs

- Frontend Integration:
  - useAIContext Hook, SuggestionSidebar, SuggestionBadge
  - ProactiveAISettings Page, Search API Client
  - Globale Suche auf neue API umgestellt

- Tests: test_unified_search.py + test_ai_proactive.py (alle bestanden)
- Config: Ollama Cloud DeepSeek V4 als Default, konfigurierbar
- Dependencies: PyMuPDF, python-docx, python-pptx, pgvector
- Bugfixes: notification type_key length, migration IF NOT EXISTS
This commit is contained in:
Agent Zero
2026-07-18 11:21:51 +02:00
parent 4c9295de21
commit 8cebb4f4e9
52 changed files with 6493 additions and 41 deletions
+440
View File
@@ -0,0 +1,440 @@
"""Tests for the AI Proactive plugin — Context API, Suggestions API,
Dismiss API, Settings API, Stats API, and Proactive Engine unit tests.
Uses pytest + pytest-asyncio, httpx.AsyncClient, login_client,
seed_tenant_and_users, ORIGIN_HEADER from conftest.py.
Mocks litellm.acompletion and litellm.aembedding to avoid real API calls.
"""
from __future__ import annotations
import asyncio
import json
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
# Import models at module level so Base.metadata.create_all includes their tables
from app.plugins.builtins.ai_proactive.models import ( # noqa: F401
ContextLog,
ProactiveSettings,
ProactiveSuggestion,
)
from app.plugins.builtins.ai_assistant.models import ( # noqa: F401
AIProvider, AIModel, AIPreset, AIAgent, AIChatSession, AIChatMessage,
AIChatFolder, AIChatAttachment,
)
from app.plugins.builtins.unified_search.models import ( # noqa: F401
SearchProviderRegistry as SearchProviderRegistryModel,
SearchIndexLog,
)
from app.plugins.builtins.ai_proactive import AIProactivePlugin
from app.plugins.builtins.ai_assistant import AIAssistantPlugin
from app.plugins.builtins.unified_search import UnifiedSearchPlugin
from app.core.db import close_engine, reset_engine_for_testing
from app.core.service_container import get_container
from app.main import create_app
from app.plugins.registry import reset_registry_for_testing
from app.services.plugin_service import reset_plugin_service_for_testing
from tests.conftest import ORIGIN_HEADER, _get_sync_engine, login_client, seed_tenant_and_users
# ─── AI Proactive Fixtures ───
@pytest_asyncio.fixture(autouse=True)
async def clean_ai_proactive_tables():
"""Truncate ai_proactive tables before each test (not in conftest TRUNCATE list)."""
sync_eng = _get_sync_engine()
with sync_eng.connect() as conn:
conn.execute(
text(
"TRUNCATE TABLE ai_proactive_suggestions, "
"ai_proactive_context_log, ai_proactive_settings CASCADE;"
)
)
conn.commit()
sync_eng.dispose()
yield
@pytest_asyncio.fixture
async def ai_proactive_app(engine: AsyncEngine, redis_client):
"""FastAPI app with AI Proactive plugin registered, installed, and activated."""
reset_engine_for_testing(engine)
app = create_app()
registry = reset_registry_for_testing()
registry.initialize(engine, app)
container = get_container()
await container.initialize()
registry.register_plugin(AIAssistantPlugin())
registry.register_plugin(UnifiedSearchPlugin())
registry.register_plugin(AIProactivePlugin())
reset_plugin_service_for_testing(registry)
sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
async with sf() as session:
# Install dependencies first, then ai_proactive
await registry.install(session, "ai_assistant")
await registry.activate(session, "ai_assistant")
await registry.install(session, "unified_search")
await registry.activate(session, "unified_search")
await registry.install(session, "ai_proactive")
await registry.activate(session, "ai_proactive")
await session.commit()
yield app
await close_engine()
@pytest_asyncio.fixture
async def ai_proactive_client(ai_proactive_app) -> AsyncClient:
"""HTTP test client with ai_proactive plugin active."""
transport = ASGITransport(app=ai_proactive_app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest_asyncio.fixture
async def ai_proactive_authed_client(
ai_proactive_client: AsyncClient, db_session: AsyncSession
) -> tuple[AsyncClient, dict]:
"""Authenticated admin client with seeded data and ai_proactive plugin active."""
seed = await seed_tenant_and_users(db_session)
# Grant is_system_admin so require_permission passes for ai_proactive:* permissions
from sqlalchemy import update
from app.models.user import User
await db_session.execute(
update(User).where(User.id == seed["admin_a"].id).values(is_system_admin=True)
)
await db_session.commit()
# Login and capture CSRF token
login_resp = await ai_proactive_client.post(
"/api/v1/auth/login",
json={"email": "admin@tenanta.com", "password": "TestPass123!"},
headers=ORIGIN_HEADER,
)
assert login_resp.status_code == 200, f"Login failed: {login_resp.text}"
csrf_token = login_resp.json().get("csrf_token", "")
# Store CSRF token on client for use in requests
ai_proactive_client.headers["X-CSRF-Token"] = csrf_token
return ai_proactive_client, seed
@pytest.fixture(autouse=True)
async def mock_litellm():
"""Mock litellm.acompletion and litellm.aembedding for all tests."""
mock_resp = MagicMock()
mock_resp.choices = [MagicMock()]
mock_resp.choices[0].message.content = json.dumps({
"suggestion_type": "info",
"title": "Test Suggestion",
"content": "This is a test suggestion",
"confidence": 0.8,
"actions": [
{
"method": "GET",
"path": "/api/v1/contacts",
"body": {},
"description": "View contacts",
}
],
})
mock_emb_resp = MagicMock()
mock_emb_resp.data = [{"embedding": [0.1] * 768}]
with (
patch("litellm.acompletion", new_callable=AsyncMock, return_value=mock_resp),
patch("litellm.aembedding", new_callable=AsyncMock, return_value=mock_emb_resp),
):
yield
# ─── 1. Context API (POST /api/v1/ai-proactive/context) ───
@pytest.mark.asyncio
async def test_context_report_accepted(
ai_proactive_authed_client: tuple[AsyncClient, dict],
):
"""Context-Report wird akzeptiert (200)."""
client, _ = ai_proactive_authed_client
resp = await client.post(
"/api/v1/ai-proactive/context",
json={
"page": "/contacts",
"entity_type": "contact",
"entity_id": str(uuid.uuid4()),
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
@pytest.mark.asyncio
async def test_context_unauthorized(ai_proactive_client: AsyncClient):
"""Ohne Auth → 403 (CSRF middleware blocks before auth check on POST)."""
resp = await ai_proactive_client.post(
"/api/v1/ai-proactive/context",
json={"page": "/contacts"},
headers=ORIGIN_HEADER,
)
# Without a session cookie, CSRF middleware returns 403 before auth is checked
assert resp.status_code in (401, 403)
# ─── 2. Suggestions API (GET /api/v1/ai-proactive/suggestions) ───
@pytest.mark.asyncio
async def test_get_suggestions(
ai_proactive_authed_client: tuple[AsyncClient, dict],
db_session: AsyncSession,
):
"""Gibt Suggestions für User zurück."""
client, seed = ai_proactive_authed_client
suggestion = ProactiveSuggestion(
tenant_id=seed["tenant_a"].id,
user_id=seed["admin_a"].id,
entity_type="contact",
entity_id=uuid.uuid4(),
suggestion_type="info",
title="Test Suggestion",
content="Test content",
confidence=0.8,
actions=[],
context_snapshot={},
)
db_session.add(suggestion)
await db_session.commit()
resp = await client.get(
"/api/v1/ai-proactive/suggestions",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert data["total"] >= 1
titles = [item["title"] for item in data["items"]]
assert "Test Suggestion" in titles
@pytest.mark.asyncio
async def test_suggestions_tenant_isolation(
ai_proactive_authed_client: tuple[AsyncClient, dict],
db_session: AsyncSession,
):
"""Nur eigene Tenant — Tenant B suggestions are not visible to Tenant A."""
client, seed = ai_proactive_authed_client
# Suggestion for tenant B user
suggestion_b = ProactiveSuggestion(
tenant_id=seed["tenant_b"].id,
user_id=seed["admin_b"].id,
entity_type="contact",
entity_id=uuid.uuid4(),
suggestion_type="info",
title="Tenant B Secret",
content="Should not be visible to A",
confidence=0.8,
actions=[],
context_snapshot={},
)
# Suggestion for tenant A user
suggestion_a = ProactiveSuggestion(
tenant_id=seed["tenant_a"].id,
user_id=seed["admin_a"].id,
entity_type="contact",
entity_id=uuid.uuid4(),
suggestion_type="info",
title="Tenant A Visible",
content="Should be visible",
confidence=0.8,
actions=[],
context_snapshot={},
)
db_session.add_all([suggestion_b, suggestion_a])
await db_session.commit()
resp = await client.get(
"/api/v1/ai-proactive/suggestions",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
titles = [item["title"] for item in data["items"]]
assert "Tenant A Visible" in titles
assert "Tenant B Secret" not in titles
# ─── 3. Dismiss API (POST /api/v1/ai-proactive/suggestions/{id}/dismiss) ───
@pytest.mark.asyncio
async def test_dismiss_suggestion(
ai_proactive_authed_client: tuple[AsyncClient, dict],
db_session: AsyncSession,
):
"""Suggestion wird dismissed."""
client, seed = ai_proactive_authed_client
suggestion = ProactiveSuggestion(
tenant_id=seed["tenant_a"].id,
user_id=seed["admin_a"].id,
entity_type="contact",
entity_id=uuid.uuid4(),
suggestion_type="info",
title="Dismiss Me",
content="Please dismiss this",
confidence=0.8,
actions=[],
context_snapshot={},
)
db_session.add(suggestion)
await db_session.commit()
resp = await client.post(
f"/api/v1/ai-proactive/suggestions/{suggestion.id}/dismiss",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# Verify it no longer appears in active suggestions
resp2 = await client.get(
"/api/v1/ai-proactive/suggestions",
headers=ORIGIN_HEADER,
)
data = resp2.json()
ids = [item["id"] for item in data["items"]]
assert str(suggestion.id) not in ids
# ─── 4. Settings API (GET/PUT /api/v1/ai-proactive/settings) ───
@pytest.mark.asyncio
async def test_get_settings(
ai_proactive_authed_client: tuple[AsyncClient, dict],
):
"""Settings werden zurückgegeben."""
client, _ = ai_proactive_authed_client
resp = await client.get(
"/api/v1/ai-proactive/settings",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert "enabled" in data
assert "suggestion_categories" in data
assert "confidence_threshold" in data
assert "rate_limit_seconds" in data
assert "model" in data
assert "available_models" in data
@pytest.mark.asyncio
async def test_update_settings(
ai_proactive_authed_client: tuple[AsyncClient, dict],
):
"""Settings können aktualisiert werden."""
client, _ = ai_proactive_authed_client
resp = await client.put(
"/api/v1/ai-proactive/settings",
json={"enabled": False, "confidence_threshold": 0.7},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["enabled"] is False
assert data["confidence_threshold"] == 0.7
@pytest.mark.asyncio
async def test_update_model(
ai_proactive_authed_client: tuple[AsyncClient, dict],
):
"""Model kann geändert werden."""
client, _ = ai_proactive_authed_client
resp = await client.put(
"/api/v1/ai-proactive/settings",
json={"model": "ollama/llama3.2"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["model"] == "ollama/llama3.2"
# ─── 5. Stats API (GET /api/v1/ai-proactive/stats) ───
@pytest.mark.asyncio
async def test_get_stats(
ai_proactive_authed_client: tuple[AsyncClient, dict],
):
"""Stats werden zurückgegeben."""
client, _ = ai_proactive_authed_client
resp = await client.get(
"/api/v1/ai-proactive/stats",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert "total_suggestions" in data
assert "dismissed" in data
assert "acted_upon" in data
assert "active" in data
assert "dismiss_rate" in data
assert "act_rate" in data
# ─── 6. Proactive Engine (Unit Tests with mocks) ───
@pytest.mark.asyncio
async def test_sse_push():
"""Suggestion wird in Queue gepusht."""
from app.plugins.builtins.ai_proactive.services import get_sse_queue, push_suggestion
user_id = f"test-sse-{uuid.uuid4()}"
queue = get_sse_queue(user_id)
# Ensure queue is empty
while not queue.empty():
await queue.get()
suggestion = {"id": "test-1", "title": "Test SSE Push"}
await push_suggestion(user_id, suggestion)
item = await asyncio.wait_for(queue.get(), timeout=1.0)
assert item["id"] == "test-1"
assert item["title"] == "Test SSE Push"
@pytest.mark.asyncio
async def test_rate_limiting(redis_client):
"""Rate-Limit funktioniert."""
from app.plugins.builtins.ai_proactive.services import is_rate_limited
tenant_id = uuid.uuid4()
user_id = uuid.uuid4()
rate_limit_seconds = 10
with patch(
"app.plugins.builtins.ai_proactive.services.get_cache",
return_value=redis_client,
):
# First call should not be rate limited
limited = await is_rate_limited(tenant_id, user_id, rate_limit_seconds)
assert limited is False
# Second call should be rate limited
limited = await is_rate_limited(tenant_id, user_id, rate_limit_seconds)
assert limited is True