8cebb4f4e9
- 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
317 lines
9.5 KiB
Python
317 lines
9.5 KiB
Python
"""API routes for the Unified Search plugin."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.db import get_db
|
|
from app.core.jobs import enqueue_job
|
|
from app.deps import get_current_user, require_permission
|
|
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
|
|
from app.plugins.builtins.unified_search.query_understanding import (
|
|
llm_aggregate_results,
|
|
llm_analyze_query,
|
|
)
|
|
from app.plugins.builtins.unified_search.schemas import (
|
|
ProviderResponse,
|
|
ReindexRequest,
|
|
SearchRequest,
|
|
SearchResponse,
|
|
SearchResult,
|
|
SimilarRequest,
|
|
SimilarResponse,
|
|
SuggestRequest,
|
|
SuggestResponse,
|
|
)
|
|
from app.plugins.builtins.unified_search.search_engine import (
|
|
autocomplete,
|
|
find_similar_all_types,
|
|
hybrid_search,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
|
|
|
|
|
# ─── Search ───
|
|
|
|
@router.post("", dependencies=[Depends(require_permission("search:read"))])
|
|
async def search(
|
|
req: SearchRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> SearchResponse:
|
|
"""Perform hybrid search with KI query understanding."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
|
|
# KI query understanding
|
|
query_analysis = await llm_analyze_query(req.query)
|
|
|
|
# Hybrid search
|
|
results = await hybrid_search(
|
|
db=db,
|
|
query_analysis=query_analysis,
|
|
tenant_id=tenant_id,
|
|
entity_types=req.entity_types,
|
|
limit=req.limit,
|
|
)
|
|
|
|
# KI result aggregation
|
|
aggregation = await llm_aggregate_results(results, req.query)
|
|
|
|
search_results = [
|
|
SearchResult(
|
|
entity_type=r.get("entity_type", ""),
|
|
entity_id=r.get("entity_id", ""),
|
|
title=r.get("title", ""),
|
|
snippet=r.get("snippet", ""),
|
|
score=r.get("score", 0.0),
|
|
data=r.get("data", {}),
|
|
)
|
|
for r in results
|
|
]
|
|
|
|
return SearchResponse(
|
|
query=req.query,
|
|
normalized_query=query_analysis.get("normalized_query", req.query),
|
|
results=search_results,
|
|
facets=aggregation.get("facets", {}),
|
|
summary=aggregation.get("summary", f"{len(results)} Ergebnisse"),
|
|
suggestions=aggregation.get("suggestions", []),
|
|
)
|
|
|
|
|
|
# ─── Suggest / Autocomplete ───
|
|
|
|
@router.get("/suggest", dependencies=[Depends(require_permission("search:read"))])
|
|
async def suggest(
|
|
q: str = Query(..., min_length=1, max_length=200),
|
|
limit: int = Query(default=10, ge=1, le=50),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> SuggestResponse:
|
|
"""Autocomplete suggestions using FTS prefix search."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
suggestions = await autocomplete(db, q, tenant_id, limit)
|
|
return SuggestResponse(suggestions=suggestions)
|
|
|
|
|
|
# ─── Similar ───
|
|
|
|
@router.post("/similar", dependencies=[Depends(require_permission("search:read"))])
|
|
async def find_similar(
|
|
req: SimilarRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> SimilarResponse:
|
|
"""Find similar entities across all types based on embedding."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
try:
|
|
entity_id = uuid.UUID(req.entity_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid entity_id")
|
|
|
|
similar = await find_similar_all_types(
|
|
db=db,
|
|
entity_type=req.entity_type,
|
|
entity_id=entity_id,
|
|
tenant_id=tenant_id,
|
|
limit=req.limit,
|
|
)
|
|
|
|
result_dict: dict[str, list[SearchResult]] = {}
|
|
for etype, items in similar.items():
|
|
result_dict[etype] = [
|
|
SearchResult(
|
|
entity_type=r.get("entity_type", etype),
|
|
entity_id=r.get("entity_id", ""),
|
|
title=r.get("title", ""),
|
|
snippet=r.get("snippet", ""),
|
|
score=r.get("score", 0.0),
|
|
data=r.get("data", {}),
|
|
)
|
|
for r in items
|
|
]
|
|
|
|
return SimilarResponse(similar=result_dict)
|
|
|
|
|
|
# ─── Reindex ───
|
|
|
|
@router.post("/reindex", dependencies=[Depends(require_permission("search:admin"))])
|
|
async def reindex(
|
|
req: ReindexRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict:
|
|
"""Trigger reindexing of specified entity types."""
|
|
entity_types = req.entity_types
|
|
if not entity_types:
|
|
entity_types = get_search_registry().get_entity_types()
|
|
|
|
job_ids: list[str] = []
|
|
for etype in entity_types:
|
|
job_id = await enqueue_job("reindex", etype)
|
|
if job_id:
|
|
job_ids.append(job_id)
|
|
|
|
return {
|
|
"status": "ok",
|
|
"message": f"Reindexing {len(entity_types)} entity types",
|
|
"entity_types": entity_types,
|
|
"job_ids": job_ids,
|
|
}
|
|
|
|
|
|
# ─── Providers ───
|
|
|
|
@router.get("/providers", dependencies=[Depends(require_permission("search:read"))])
|
|
async def list_providers(
|
|
current_user: dict = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> list[ProviderResponse]:
|
|
"""List all active search providers."""
|
|
registry = get_search_registry()
|
|
providers = registry.get_all()
|
|
return [
|
|
ProviderResponse(
|
|
entity_type=p.entity_type,
|
|
plugin_name="unified_search",
|
|
is_active=True,
|
|
)
|
|
for p in providers
|
|
]
|
|
|
|
|
|
@router.post("/providers/{entity_type}/toggle", dependencies=[Depends(require_permission("search:admin"))])
|
|
async def toggle_provider(
|
|
entity_type: str,
|
|
current_user: dict = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict:
|
|
"""Toggle a search provider on/off."""
|
|
registry = get_search_registry()
|
|
provider = registry.get(entity_type)
|
|
if provider is None:
|
|
raise HTTPException(status_code=404, detail=f"Provider not found: {entity_type}")
|
|
|
|
# Toggle in DB
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
result = await db.execute(
|
|
text(
|
|
"""
|
|
SELECT is_active FROM unified_search_providers
|
|
WHERE tenant_id = :tid AND entity_type = :et
|
|
"""
|
|
),
|
|
{"tid": tenant_id, "et": entity_type},
|
|
)
|
|
row = result.mappings().first()
|
|
|
|
if row:
|
|
new_status = not row["is_active"]
|
|
await db.execute(
|
|
text(
|
|
"""
|
|
UPDATE unified_search_providers SET is_active = :active
|
|
WHERE tenant_id = :tid AND entity_type = :et
|
|
"""
|
|
),
|
|
{"active": new_status, "tid": tenant_id, "et": entity_type},
|
|
)
|
|
else:
|
|
new_status = False
|
|
await db.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO unified_search_providers (tenant_id, entity_type, plugin_name, is_active)
|
|
VALUES (:tid, :et, 'unified_search', false)
|
|
"""
|
|
),
|
|
{"tid": tenant_id, "et": entity_type},
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
if not new_status:
|
|
registry.unregister(entity_type)
|
|
else:
|
|
# Re-register by clearing and re-running auto_register
|
|
from app.plugins.builtins.unified_search.provider_registry import auto_register_providers
|
|
await auto_register_providers(db)
|
|
|
|
return {
|
|
"entity_type": entity_type,
|
|
"is_active": new_status,
|
|
}
|
|
|
|
|
|
# ─── Stats ───
|
|
|
|
@router.get("/stats", dependencies=[Depends(require_permission("search:read"))])
|
|
async def search_stats(
|
|
current_user: dict = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict:
|
|
"""Get search index statistics."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
|
|
stats: dict = {}
|
|
tables = {
|
|
"contacts": "embedding",
|
|
"companies": "embedding",
|
|
"mails": "embedding",
|
|
"files": "embedding",
|
|
"calendar_entries": "embedding",
|
|
}
|
|
|
|
for table, col in tables.items():
|
|
try:
|
|
total_result = await db.execute(
|
|
text(f"SELECT count(*) AS cnt FROM {table} WHERE tenant_id = :tid AND deleted_at IS NULL"),
|
|
{"tid": tenant_id},
|
|
)
|
|
total = total_result.scalar() or 0
|
|
|
|
indexed_result = await db.execute(
|
|
text(f"SELECT count(*) AS cnt FROM {table} WHERE tenant_id = :tid AND deleted_at IS NULL AND {col} IS NOT NULL"),
|
|
{"tid": tenant_id},
|
|
)
|
|
indexed = indexed_result.scalar() or 0
|
|
|
|
stats[table] = {
|
|
"total": total,
|
|
"indexed": indexed,
|
|
"pending": total - indexed,
|
|
}
|
|
except Exception:
|
|
logger.exception("Stats query failed for %s", table)
|
|
stats[table] = {"total": 0, "indexed": 0, "pending": 0}
|
|
|
|
# Last index log entries
|
|
try:
|
|
log_result = await db.execute(
|
|
text(
|
|
"""
|
|
SELECT entity_type, action, status, created_at
|
|
FROM unified_search_index_log
|
|
WHERE tenant_id = :tid
|
|
ORDER BY created_at DESC
|
|
LIMIT 10
|
|
"""
|
|
),
|
|
{"tid": tenant_id},
|
|
)
|
|
recent_logs = [dict(r) for r in log_result.mappings().all()]
|
|
except Exception:
|
|
recent_logs = []
|
|
|
|
stats["recent_logs"] = recent_logs
|
|
return stats
|