feat(E): Unified Search — 24 Tasks complete
Check Cross-Plugin Imports / check (push) Has been cancelled

- 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.
This commit is contained in:
Agent Zero
2026-08-14 01:34:58 +02:00
parent 60f30d021b
commit 3d9b76cea4
45 changed files with 5378 additions and 402 deletions
+109 -2
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import uuid
from typing import Any
from app.plugins.base import BasePlugin
@@ -35,9 +36,17 @@ class UnifiedSearchPlugin(BasePlugin):
"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"],
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=[
@@ -49,7 +58,7 @@ class UnifiedSearchPlugin(BasePlugin):
)
async def on_activate(self, db, service_container, event_bus) -> None:
"""Register search providers on activation."""
"""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 (
@@ -60,6 +69,15 @@ class UnifiedSearchPlugin(BasePlugin):
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
@@ -111,6 +129,95 @@ class UnifiedSearchPlugin(BasePlugin):
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 [
{