"""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, db=db, tenant_id=tenant_id) # 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, db=db, tenant_id=tenant_id) 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