Files
leocrm/app/plugins/builtins/unified_search/lifecycle.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

139 lines
4.2 KiB
Python

"""Derived-data lifecycle management for the Unified Search index.
Provides functions to remove, rebuild, and purge search index data
(embeddings, FTS vectors, document chunks) when entities are deleted,
restored, or corrected.
"""
from __future__ import annotations
import logging
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import text
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
# Entity type -> (table_name, tsv_column, embedding_column)
_ENTITY_MAP: dict[str, tuple[str, str, str]] = {
"contact": ("contacts", "search_tsv", "embedding"),
"mail": ("mails", "body_tsv", "embedding"),
"file": ("files", "content_tsv", "embedding"),
"event": ("calendar_entries", "search_tsv", "embedding"),
}
def _resolve_entity(entity_type: str) -> tuple[str, str, str] | None:
"""Return (table, tsv_column, embedding_column) for an entity type."""
return _ENTITY_MAP.get(entity_type)
async def remove_from_index(
db: "AsyncSession",
entity_type: str,
entity_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> None:
"""Remove an entity from the search index (embedding=NULL, TSV=NULL).
Does **not** delete document_chunks — use :func:`remove_chunks` for that.
"""
mapping = _resolve_entity(entity_type)
if mapping is None:
logger.warning("remove_from_index: unknown entity_type=%s", entity_type)
return
table, tsv_col, emb_col = mapping
await db.execute(
text(
f"UPDATE {table} "
f"SET {emb_col} = NULL, {tsv_col} = NULL, indexed_at = NULL "
f"WHERE id = :eid AND tenant_id = :tid"
),
{"eid": entity_id, "tid": tenant_id},
)
await db.commit()
logger.info("Removed %s/%s from search index", entity_type, entity_id)
async def remove_chunks(
db: "AsyncSession",
file_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> None:
"""Remove all document_chunks for a file."""
await db.execute(
text(
"DELETE FROM document_chunks "
"WHERE file_id = :fid AND tenant_id = :tid"
),
{"fid": file_id, "tid": tenant_id},
)
await db.commit()
logger.info("Removed chunks for file %s", file_id)
async def rebuild_index(
db: "AsyncSession",
entity_type: str,
entity_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> bool:
"""Rebuild the search index for a single entity from the authoritative source.
Re-generates the embedding and refreshes the TSV vector.
Returns True on success, False on failure.
"""
from app.plugins.builtins.unified_search.embedding import index_entity
# Clear existing index data first
await remove_from_index(db, entity_type, entity_id, tenant_id)
# Re-generate embedding (index_entity also updates indexed_at)
try:
success = await index_entity(entity_type, entity_id, tenant_id, db)
if success and entity_type == "file":
# Re-index chunks for files
from app.plugins.builtins.unified_search.jobs import index_file_chunks
await index_file_chunks({"_lifecycle": True}, str(entity_id))
return success
except Exception:
logger.exception("rebuild_index failed for %s/%s", entity_type, entity_id)
return False
async def handle_entity_delete(
db: "AsyncSession",
entity_type: str,
entity_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> None:
"""Handle entity deletion: remove from index + remove chunks if applicable."""
await remove_from_index(db, entity_type, entity_id, tenant_id)
if entity_type == "file":
await remove_chunks(db, entity_id, tenant_id)
async def handle_entity_restore(
db: "AsyncSession",
entity_type: str,
entity_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> None:
"""Handle entity restore from trash: rebuild search index."""
await rebuild_index(db, entity_type, entity_id, tenant_id)
async def handle_entity_correction(
db: "AsyncSession",
entity_type: str,
entity_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> None:
"""Handle entity data correction: rebuild search index."""
await rebuild_index(db, entity_type, entity_id, tenant_id)