"""Base search provider with visibility filter — all search providers should inherit from this. This ensures that search results respect row-level security automatically. Plugins that provide search functionality should use this base class. """ from __future__ import annotations import logging import uuid from typing import Any from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings logger = logging.getLogger(__name__) class BaseSearchProvider: """Base class for search providers with built-in visibility filtering. Subclasses must implement _search_fts_filtered() and _search_vector_filtered(). The base class handles loading visible IDs and passing them to the subclass. """ # Capability flags — override in subclass supports_fts: bool = True supports_vector: bool = True supports_rag: bool = False supports_graph: bool = False entity_type: str = "" # Override in subclass async def search_fts( self, db: AsyncSession, tsquery: str, tenant_id: uuid.UUID, limit: int, user_id: uuid.UUID | None = None, is_system_admin: bool = False, ) -> list[dict[str, Any]]: """Full-text search with visibility filter.""" if is_system_admin or not user_id: return await self._search_fts_filtered(db, tsquery, tenant_id, limit, None) visible_ids = await self._get_visible_ids(db, tenant_id, user_id) if not visible_ids: return [] return await self._search_fts_filtered(db, tsquery, tenant_id, limit, visible_ids) async def search_vector( self, db: AsyncSession, embedding: list[float], tenant_id: uuid.UUID, limit: int, user_id: uuid.UUID | None = None, is_system_admin: bool = False, ) -> list[dict[str, Any]]: """Semantic vector search with visibility filter. Uses over-fetch strategy: fetch limit*3 from HNSW without permission filter, then post-filter in Python. This avoids the 15x performance hit of `id = ANY($uuid[])` on HNSW results found in SPIKE-E. """ # Set HNSW ef_search parameter for this transaction await db.execute(text(f"SET LOCAL hnsw.ef_search = {settings.hnsw_ef_search}")) if is_system_admin or not user_id: return await self._search_vector_filtered(db, embedding, tenant_id, limit, None) visible_ids = await self._get_visible_ids(db, tenant_id, user_id) if not visible_ids: return [] # Over-fetch 3x the limit, then post-filter in Python over_fetch_limit = limit * 3 results = await self._search_vector_filtered(db, embedding, tenant_id, over_fetch_limit, None) filtered = [r for r in results if r.get("id") in visible_ids] return filtered[:limit] async def _get_visible_ids( self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID ) -> set[uuid.UUID] | None: """Get visible entity IDs for the user.""" from app.services.entity_permission_service import get_visible_ids visible, _ = await get_visible_ids(db, tenant_id, user_id, self.entity_type) return visible if visible else None async def _search_fts_filtered( self, db: AsyncSession, tsquery: str, tenant_id: uuid.UUID, limit: int, visible_ids: set[uuid.UUID] | None, ) -> list[dict[str, Any]]: """Override: FTS search filtered by visible_ids. If visible_ids is None, no filter.""" raise NotImplementedError async def _search_vector_filtered( self, db: AsyncSession, embedding: list[float], tenant_id: uuid.UUID, limit: int, visible_ids: set[uuid.UUID] | None, ) -> list[dict[str, Any]]: """Override: Vector search filtered by visible_ids. If visible_ids is None, no filter.""" raise NotImplementedError async def get_embedding_text( self, db: AsyncSession, entity_id: uuid.UUID, tenant_id: uuid.UUID ) -> str: """Override: Get text for embedding generation.""" raise NotImplementedError