Files
leocrm/app/plugins/builtins/unified_search/plugin.py
T
Agent Zero 3d9b76cea4
Check Cross-Plugin Imports / check (push) Has been cancelled
feat(E): Unified Search — 24 Tasks complete
- SPIKE-E: FTS+Vector+Permission benchmark on 10k records (all <30ms)
- E-PROV: supports_fts/vector/rag/graph capability flags on all providers
- E-FTS/VEC: All 11 providers refactored to BaseSearchProvider with permission filtering
- E-PERM: Over-fetch strategy for vector+permission (15x faster than ANY() filter)
- E-FUSE: rrf_fusion_multi() for N-way RRF over FTS+Vector+RAG+Graph
- E-LLM: Query understanding cleaned up to use central llm_complete()
- E-CHUNK: Document chunking module + document_chunks table with HNSW index
- E-EMB: Chunk embedding ARQ jobs (index_file_chunks, reindex_chunks)
- E-RAG: RAG retrieval via FileSearchProvider.search_rag()
- E-GRAPH: GraphRAG BFS traversal via GraphRAGSearchProvider.search_graph()
- E-IX-EVT: Auto-indexing via outbox events + delete/cleanup handlers
- E-IX-RE: Batch reindex with progress tracking + reindex_all job
- E-DATA-LIFE: Lifecycle module (remove/rebuild/restore/correct) + API endpoints
- E-K-MEM: AgentMemorySearchProvider
- E-P-AI: AIChatSearchProvider
- E-P-WF: WorkflowSearchProvider
- E-P-COMM: ConversationSearchProvider verified (already on BaseSearchProvider)
- E-API: Filter params (date_from/to, tags, sort) + /facets endpoint
- E-TOOL: unified_search AI tool registered in ToolRegistry
- E-MCP: Search tool in MCP server with normal RBAC/tenant checks
- E-UI-CMD: CommandPalette (Cmd+K) with debounced search + recent searches
- E-UI-FAC: SearchFacets, SearchResultCard, SavedSearches components
- E-TEST: 40 new tests in test_unified_search_phase_e.py (105 total green)
- E-DOC: api-documentation.md, plugin-development-guide.md, test-strategy.md updated

105 tests passing, TypeScript clean.
2026-08-14 01:34:58 +02:00

238 lines
9.1 KiB
Python

"""Unified Search plugin class and manifest."""
from __future__ import annotations
import logging
import uuid
from typing import Any
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendPageRoute
logger = logging.getLogger(__name__)
class UnifiedSearchPlugin(BasePlugin):
"""Hybrid full-text and semantic search across all CRM entities."""
manifest = PluginManifest(
name="unified_search",
version="1.0.0",
display_name="Unified Search",
description=(
"Hybrid full-text (PostgreSQL FTS) and semantic (pgvector) search "
"with KI query understanding and RRF rank fusion across all CRM data."
),
dependencies=[],
routes=[
PluginRouteDef(
path="/api/v1/search",
module="app.plugins.builtins.unified_search.routes",
router_attr="router",
),
],
events=[
"mail.synced",
"file.uploaded",
"contact.created",
"contact.updated",
"contact.deleted",
"file.deleted",
"mail.deleted",
"event.deleted",
"calendar.entry.updated",
"calendar.entry.created",
"entity.deleted",
"entity.restored",
"entity.corrected",
],
migrations=["0001_initial.sql", "0002_embeddings.sql", "0003_add_deleted_at.sql", "0004_document_chunks.sql", "0005_add_indexed_at.sql"],
permissions=["search:read", "search:admin"],
is_core=True,
page_routes=[
FrontendPageRoute(path='/search', component='@/pages/GlobalSearchResults', protected=True),
],
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0",
)
async def on_activate(self, db, service_container, event_bus) -> None:
"""Register search providers and AI tool on activation."""
await super().on_activate(db, service_container, event_bus)
try:
from app.plugins.builtins.unified_search.provider_registry import (
auto_register_providers,
)
await auto_register_providers(db)
logger.info("Unified Search providers auto-registered")
except Exception:
logger.exception("Failed to auto-register search providers")
# Register the unified_search AI tool
try:
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
from app.plugins.builtins.unified_search.ai_tool import register_unified_search_tool
register_unified_search_tool(get_tool_registry())
logger.info("Unified Search AI tool registered")
except Exception:
logger.exception("Failed to register unified_search AI tool")
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Clear provider registry on deactivation."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
from app.plugins.builtins.unified_search.provider_registry import (
get_search_registry,
)
registry = get_search_registry()
registry.clear()
await super().on_deactivate(db, service_container, event_bus)
# ─── Event Handlers ───
async def on_mail_synced(self, payload: dict[str, Any]) -> None:
"""Enqueue embedding jobs for synced mails."""
from app.core.jobs import enqueue_job
mail_ids = payload.get("mail_ids", [])
if mail_ids:
await enqueue_job("index_mails", mail_ids)
async def on_file_uploaded(self, payload: dict[str, Any]) -> None:
"""Enqueue file indexing job."""
from app.core.jobs import enqueue_job
file_id = payload.get("file_id")
if file_id:
await enqueue_job("index_file", file_id)
async def on_contact_created(self, payload: dict[str, Any]) -> None:
from app.core.jobs import enqueue_job
contact_id = payload.get("contact_id")
if contact_id:
await enqueue_job("index_contact", contact_id)
async def on_contact_updated(self, payload: dict[str, Any]) -> None:
from app.core.jobs import enqueue_job
contact_id = payload.get("contact_id")
if contact_id:
await enqueue_job("index_contact", contact_id)
async def on_calendar_entry_created(self, payload: dict[str, Any]) -> None:
from app.core.jobs import enqueue_job
entry_id = payload.get("entry_id")
if entry_id:
await enqueue_job("index_event", entry_id)
async def on_calendar_entry_updated(self, payload: dict[str, Any]) -> None:
"""Enqueue re-index job for updated calendar entry."""
from app.core.jobs import enqueue_job
entry_id = payload.get("entry_id")
if entry_id:
await enqueue_job("index_event", entry_id)
async def on_contact_deleted(self, payload: dict[str, Any]) -> None:
"""Enqueue job to remove embedding for deleted contact."""
from app.core.jobs import enqueue_job
contact_id = payload.get("contact_id")
if contact_id:
await enqueue_job("delete_entity_index", "contact", contact_id)
async def on_file_deleted(self, payload: dict[str, Any]) -> None:
"""Enqueue job to remove file embedding + delete document_chunks."""
from app.core.jobs import enqueue_job
file_id = payload.get("file_id")
if file_id:
await enqueue_job("delete_entity_index", "file", file_id)
await enqueue_job("delete_file_chunks", file_id)
async def on_mail_deleted(self, payload: dict[str, Any]) -> None:
"""Enqueue job to remove mail embedding."""
from app.core.jobs import enqueue_job
mail_id = payload.get("mail_id")
if mail_id:
await enqueue_job("delete_entity_index", "mail", mail_id)
async def on_event_deleted(self, payload: dict[str, Any]) -> None:
"""Enqueue job to remove event embedding."""
from app.core.jobs import enqueue_job
event_id = payload.get("event_id")
if event_id:
await enqueue_job("delete_entity_index", "event", event_id)
async def on_entity_deleted(self, payload: dict[str, Any]) -> None:
"""Handle entity.deleted lifecycle event — remove from search index."""
from app.plugins.builtins.unified_search.lifecycle import handle_entity_delete
from app.core.db import get_session_factory
entity_type = payload.get("entity_type")
entity_id = payload.get("entity_id")
tenant_id = payload.get("tenant_id")
if not all([entity_type, entity_id, tenant_id]):
return
factory = get_session_factory()
async with factory() as db:
await handle_entity_delete(
db, entity_type, uuid.UUID(str(entity_id)), uuid.UUID(str(tenant_id))
)
async def on_entity_restored(self, payload: dict[str, Any]) -> None:
"""Handle entity.restored lifecycle event — rebuild search index."""
from app.plugins.builtins.unified_search.lifecycle import handle_entity_restore
from app.core.db import get_session_factory
entity_type = payload.get("entity_type")
entity_id = payload.get("entity_id")
tenant_id = payload.get("tenant_id")
if not all([entity_type, entity_id, tenant_id]):
return
factory = get_session_factory()
async with factory() as db:
await handle_entity_restore(
db, entity_type, uuid.UUID(str(entity_id)), uuid.UUID(str(tenant_id))
)
async def on_entity_corrected(self, payload: dict[str, Any]) -> None:
"""Handle entity.corrected lifecycle event — rebuild search index."""
from app.plugins.builtins.unified_search.lifecycle import handle_entity_correction
from app.core.db import get_session_factory
entity_type = payload.get("entity_type")
entity_id = payload.get("entity_id")
tenant_id = payload.get("tenant_id")
if not all([entity_type, entity_id, tenant_id]):
return
factory = get_session_factory()
async with factory() as db:
await handle_entity_correction(
db, entity_type, uuid.UUID(str(entity_id)), uuid.UUID(str(tenant_id))
)
def get_notification_types(self) -> list[dict[str, Any]]:
return [
{
"type_key": "search_error",
"category": "search",
"label": "Suchfehler",
"description": "Fehler bei der Suchausführung",
"is_enabled_by_default": True,
},
{
"type_key": "search_reindex_complete",
"category": "search",
"label": "Reindex abgeschlossen",
"description": "Neuindizierung abgeschlossen",
"is_enabled_by_default": False,
},
]