"""API routes for the Unified Search plugin.""" from __future__ import annotations import logging import uuid from datetime import UTC, datetime from typing import Any 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.core.permissions import filter_fields_by_permission, resolve_permissions 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 ( FacetsResponse, ProviderResponse, ReindexRequest, SearchRequest, SearchResponse, SearchResult, SimilarRequest, SimilarResponse, 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.get("", dependencies=[Depends(require_permission("search:read"))]) async def search_get( q: str = Query(..., min_length=1, max_length=500, description="Search query"), entity_types: str | None = Query(None, description="Comma-separated entity types to search"), limit: int = Query(default=20, ge=1, le=100), offset: int = Query(default=0, ge=0), date_from: str | None = Query(None, description="ISO date (YYYY-MM-DD) — filter by created_at/updated_at >= date_from"), date_to: str | None = Query(None, description="ISO date (YYYY-MM-DD) — filter by created_at/updated_at <= date_to"), tags: str | None = Query(None, description="Comma-separated tags to filter results"), sort: str = Query(default="relevance", description="Sort order: relevance, date, name"), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> SearchResponse: """Perform hybrid search via GET (same as POST but with query params).""" types_list = entity_types.split(",") if entity_types else None tags_list = tags.split(",") if tags else None req = SearchRequest( query=q, entity_types=types_list, limit=limit, offset=offset, date_from=date_from, date_to=date_to, tags=tags_list, sort=sort, ) return await _do_search(req, current_user, db) def _parse_date(value: str | None) -> datetime | None: """Parse an ISO date string (YYYY-MM-DD) into a timezone-aware datetime.""" if not value: return None try: dt = datetime.fromisoformat(value) if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC) return dt except ValueError: return None def _apply_filters_and_sort( results: list[dict], req: SearchRequest, ) -> list[dict]: """Apply post-search date/tags filters and sort order to results.""" date_from = _parse_date(req.date_from) date_to = _parse_date(req.date_to) filtered: list[dict] = [] for r in results: # Date range filter on created_at/updated_at if date_from or date_to: created = r.get("_created_at") updated = r.get("_updated_at") ts = updated or created if ts is None: continue if isinstance(ts, str): try: ts = datetime.fromisoformat(ts.replace("Z", "+00:00")) except ValueError: continue if ts.tzinfo is None: ts = ts.replace(tzinfo=UTC) if date_from and ts < date_from: continue if date_to and ts > date_to: continue # Tags filter (comma-separated on entity) if req.tags: entity_tags = r.get("_tags") or "" tag_set = {t.strip().lower() for t in entity_tags.split(",") if t.strip()} if not any(t.lower() in tag_set for t in req.tags): continue filtered.append(r) # Sort order sort = (req.sort or "relevance").lower() if sort == "date": filtered.sort( key=lambda x: (x.get("_updated_at") or x.get("_created_at") or ""), reverse=True, ) elif sort == "name": filtered.sort(key=lambda x: (x.get("title") or "").lower()) # 'relevance' keeps the existing score order return filtered async def _do_search( req: SearchRequest, current_user: dict, db: AsyncSession, ) -> SearchResponse: """Shared search logic used by both GET and POST endpoints.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("is_system_admin", False) # KI query understanding query_analysis = await llm_analyze_query(req.query, db=db, tenant_id=tenant_id) # Hybrid search with visibility filtering results = await hybrid_search( db=db, query_analysis=query_analysis, tenant_id=tenant_id, entity_types=req.entity_types, limit=req.limit, user_id=user_id, is_system_admin=is_system_admin, ) # Apply post-search filters (date range, tags) and sort results = _apply_filters_and_sort(results, req) # Resolve user permissions for field-level RBAC resolved_perms = await resolve_permissions(db, user_id, tenant_id) # Map entity_type to module name for field-level permissions _entity_to_module = { "contact": "contacts", "mail": "mail", "file": "dms", "event": "calendar", } # 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=filter_fields_by_permission( r.get("data", {}), resolved_perms, _entity_to_module.get(r.get("entity_type", ""), r.get("entity_type", "")), ), ) 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", []), ) @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.""" return await _do_search(req, current_user, db) # ─── Facets ─── @router.get("/facets", dependencies=[Depends(require_permission("search:read"))]) async def search_facets( current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> FacetsResponse: """Return available facets for search filtering (entity types, tags, date ranges).""" tenant_id = uuid.UUID(current_user["tenant_id"]) # Entity types from the search registry registry = get_search_registry() entity_types = registry.get_entity_types() # Tags: distinct tags across searchable entities (comma-separated on entities) tags: set[str] = set() tag_tables = { "contacts": "tags", "companies": "tags", } for table, col in tag_tables.items(): try: result = await db.execute( text( f"SELECT DISTINCT unnest(string_to_array({col}, ',')) AS tag " f"FROM {table} WHERE tenant_id = :tid AND deleted_at IS NULL AND {col} IS NOT NULL" ), {"tid": tenant_id}, ) for row in result.mappings().all(): tag = (row.get("tag") or "").strip() if tag: tags.add(tag) except Exception: logger.debug("Facet tag query failed for %s", table) # Date ranges: min/max created_at across searchable entities date_ranges: dict[str, Any] = {} date_tables = ["contacts", "mails", "files", "calendar_entries"] for table in date_tables: try: result = await db.execute( text( f"SELECT min(created_at) AS min_dt, max(created_at) AS max_dt " f"FROM {table} WHERE tenant_id = :tid AND deleted_at IS NULL" ), {"tid": tenant_id}, ) row = result.mappings().first() if row: date_ranges[table] = { "min": str(row.get("min_dt")) if row.get("min_dt") else None, "max": str(row.get("max_dt")) if row.get("max_dt") else None, } except Exception: logger.debug("Facet date query failed for %s", table) return FacetsResponse( entity_types=entity_types, tags=sorted(tags), date_ranges=date_ranges, ) # ─── 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") from None 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) # Optionally re-index file chunks if req.include_chunks and "file" in entity_types: chunk_job_id = await enqueue_job("reindex_all") if chunk_job_id: job_ids.append(chunk_job_id) return { "status": "ok", "message": f"Reindexing {len(entity_types)} entity types", "entity_types": entity_types, "include_chunks": req.include_chunks, "job_ids": job_ids, } # ─── Rebuild / Purge ─── @router.post("/rebuild/{entity_type}/{entity_id}", dependencies=[Depends(require_permission("search:admin"))]) async def rebuild_entity_index( entity_type: str, entity_id: str, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: """Rebuild the search index for a single entity (admin only).""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: eid = uuid.UUID(entity_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid entity_id") from None from app.plugins.builtins.unified_search.lifecycle import rebuild_index success = await rebuild_index(db, entity_type, eid, tenant_id) return { "status": "ok" if success else "failed", "entity_type": entity_type, "entity_id": entity_id, "message": "Index rebuilt" if success else "Rebuild failed — check logs", } @router.post("/purge/{entity_type}/{entity_id}", dependencies=[Depends(require_permission("search:admin"))]) async def purge_entity_index( entity_type: str, entity_id: str, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: """Purge an entity from the search index (admin only, GDPR).""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: eid = uuid.UUID(entity_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid entity_id") from None from app.plugins.builtins.unified_search.lifecycle import remove_chunks, remove_from_index await remove_from_index(db, entity_type, eid, tenant_id) if entity_type == "file": await remove_chunks(db, eid, tenant_id) return { "status": "ok", "entity_type": entity_type, "entity_id": entity_id, "message": "Purged from search index", } # ─── 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, supports_fts=getattr(p, "supports_fts", True), supports_vector=getattr(p, "supports_vector", True), supports_rag=getattr(p, "supports_rag", False), supports_graph=getattr(p, "supports_graph", False), ) 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", "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