diff --git a/app/plugins/builtins/unified_search/providers/wiki_provider.py b/app/plugins/builtins/unified_search/providers/wiki_provider.py new file mode 100644 index 0000000..ec4a7d6 --- /dev/null +++ b/app/plugins/builtins/unified_search/providers/wiki_provider.py @@ -0,0 +1,156 @@ +"""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", "")), + } diff --git a/app/plugins/builtins/wiki/plugin.py b/app/plugins/builtins/wiki/plugin.py index 1ec602e..2059da8 100644 --- a/app/plugins/builtins/wiki/plugin.py +++ b/app/plugins/builtins/wiki/plugin.py @@ -1,8 +1,11 @@ -"""Wiki plugin — knowledge articles, categories, versioning (H-WIKI, H-VER).""" +"""Wiki plugin — knowledge articles, categories, versioning (H-WIKI, H-VER)."" from __future__ import annotations +import logging from app.plugins.base import BasePlugin from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef +logger = logging.getLogger(__name__) + class WikiPlugin(BasePlugin): manifest = PluginManifest( name="wiki", @@ -17,3 +20,15 @@ class WikiPlugin(BasePlugin): menu_items=[FrontendMenuItem(label_key="wiki.menu.wiki", label="Wiki", path="/wiki", icon="BookOpen")], page_routes=[FrontendPageRoute(path="/wiki", component="@/pages/Wiki")], ) + + async def on_activate(self, db, service_container, event_bus) -> None: + """Register wiki search provider on activation.""" + await super().on_activate(db, service_container, event_bus) + try: + from app.plugins.builtins.unified_search.provider_registry import get_search_registry + from app.plugins.builtins.unified_search.providers.wiki_provider import WikiSearchProvider + registry = get_search_registry() + registry.register(WikiSearchProvider()) + logger.info("Registered WikiSearchProvider") + except Exception: + logger.exception("Failed to register WikiSearchProvider")