"""Wiki search provider — searches wiki articles via FTS and vector.""" from __future__ import annotations import logging import uuid from typing import Any from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.plugins.builtins.unified_search.base_provider import BaseSearchProvider logger = logging.getLogger(__name__) class WikiSearchProvider(BaseSearchProvider): """Search provider for WikiArticle entities.""" entity_type = "wiki_article" 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]]: """Full-text search on wiki_articles title + content.""" if visible_ids is not None: sql = text( """ SELECT a.id, a.title, a.slug, a.summary, a.content, a.status, a.category_id, a.tenant_id, ts_rank( to_tsvector('pg_catalog.german', coalesce(a.title, '') || ' ' || coalesce(a.content, '') || ' ' || coalesce(a.summary, '') ), to_tsquery('pg_catalog.german', :q) ) AS rank FROM wiki_articles a WHERE a.tenant_id = :tid AND a.deleted_at IS NULL AND a.status = 'published' AND to_tsvector('pg_catalog.german', coalesce(a.title, '') || ' ' || coalesce(a.content, '') || ' ' || coalesce(a.summary, '') ) @@ to_tsquery('pg_catalog.german', :q) AND a.id = ANY(:visible_ids) ORDER BY rank DESC LIMIT :lim """ ) result = await db.execute( sql, {"q": tsquery, "tid": tenant_id, "lim": limit, "visible_ids": list(visible_ids)}, ) else: sql = text( """ SELECT a.id, a.title, a.slug, a.summary, a.content, a.status, a.category_id, a.tenant_id, ts_rank( to_tsvector('pg_catalog.german', coalesce(a.title, '') || ' ' || coalesce(a.content, '') || ' ' || coalesce(a.summary, '') ), to_tsquery('pg_catalog.german', :q) ) AS rank FROM wiki_articles a WHERE a.tenant_id = :tid AND a.deleted_at IS NULL AND a.status = 'published' AND to_tsvector('pg_catalog.german', coalesce(a.title, '') || ' ' || coalesce(a.content, '') || ' ' || coalesce(a.summary, '') ) @@ to_tsquery('pg_catalog.german', :q) ORDER BY rank DESC LIMIT :lim """ ) result = await db.execute( sql, {"q": tsquery, "tid": tenant_id, "lim": limit}, ) rows = result.mappings().all() return [dict(r) for r in rows] 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]]: """Semantic vector search on wiki_articles.embedding.""" emb_str = f"[{','.join(str(x) for x in embedding)}]" if visible_ids is not None: sql = text( """ SELECT a.id, a.title, a.slug, a.summary, a.content, a.status, a.category_id, a.tenant_id, 1 - (a.embedding <=> cast(:emb AS vector)) AS similarity FROM wiki_articles a WHERE a.tenant_id = :tid AND a.deleted_at IS NULL AND a.status = 'published' AND a.embedding IS NOT NULL AND a.id = ANY(:visible_ids) ORDER BY a.embedding <=> cast(:emb AS vector) LIMIT :lim """ ) result = await db.execute( sql, {"emb": emb_str, "tid": tenant_id, "lim": limit, "visible_ids": list(visible_ids)}, ) else: sql = text( """ SELECT a.id, a.title, a.slug, a.summary, a.content, a.status, a.category_id, a.tenant_id, 1 - (a.embedding <=> cast(:emb AS vector)) AS similarity FROM wiki_articles a WHERE a.tenant_id = :tid AND a.deleted_at IS NULL AND a.status = 'published' AND a.embedding IS NOT NULL ORDER BY a.embedding <=> cast(:emb AS vector) LIMIT :lim """ ) result = await db.execute( sql, {"emb": emb_str, "tid": tenant_id, "lim": limit}, ) rows = result.mappings().all() return [dict(r) for r in rows] def to_search_result(self, entity: dict) -> dict[str, Any]: """Convert a wiki article dict to a search result.""" return { "entity_type": "wiki_article", "entity_id": str(entity.get("id", "")), "title": entity.get("title", ""), "description": entity.get("summary", "") or (entity.get("content", "")[:200] if entity.get("content") else ""), "url": f"/wiki?article={entity.get('slug', '')}", "tenant_id": str(entity.get("tenant_id", "")), }