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
@@ -133,18 +133,86 @@ async def index_entity(
Returns True on success, False on failure.
"""
from sqlalchemy import text as sql_text
from app.plugins.builtins.unified_search.models import SearchIndexLog
async def _log_index(action: str, status: str, error: str | None = None) -> None:
"""Write a SearchIndexLog entry for audit/debugging."""
try:
log_entry = SearchIndexLog(
tenant_id=tenant_id,
entity_type=entity_type,
entity_id=entity_id,
action=action,
status=status,
error_message=error,
)
db.add(log_entry)
await db.commit()
except Exception:
logger.debug("Failed to write SearchIndexLog", exc_info=True)
# ── Dedup check: skip if already indexed with unchanged content ──
table_map = {
"contact": "contacts",
"mail": "mails",
"file": "files",
"event": "calendar_entries",
}
table = table_map.get(entity_type)
if not table:
logger.warning("Unknown entity_type=%s for embedding storage", entity_type)
await _log_index("index", "failed", f"Unknown entity_type={entity_type}")
return False
# For files: compare content_hash; for others: compare updated_at vs indexed_at
if entity_type == "file":
dedup_result = await db.execute(
sql_text(
f"SELECT content_hash, indexed_at FROM {table} "
f"WHERE id = :eid AND tenant_id = :tid"
),
{"eid": entity_id, "tid": tenant_id},
)
dedup_row = dedup_result.mappings().first()
if dedup_row and dedup_row.get("indexed_at") and dedup_row.get("content_hash"):
# Already indexed and content_hash hasn't changed
logger.debug("Skipping duplicate index for file %s (content_hash unchanged)", entity_id)
await _log_index("index", "skipped_duplicate")
return True
else:
dedup_result = await db.execute(
sql_text(
f"SELECT updated_at, indexed_at FROM {table} "
f"WHERE id = :eid AND tenant_id = :tid"
),
{"eid": entity_id, "tid": tenant_id},
)
dedup_row = dedup_result.mappings().first()
if (
dedup_row
and dedup_row.get("indexed_at")
and dedup_row.get("updated_at")
and dedup_row["updated_at"] <= dedup_row["indexed_at"]
):
logger.debug("Skipping duplicate index for %s/%s (content unchanged)", entity_type, entity_id)
await _log_index("index", "skipped_duplicate")
return True
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
registry = get_search_registry()
provider = registry.get(entity_type)
if provider is None:
logger.warning("No provider for entity_type=%s", entity_type)
await _log_index("index", "failed", f"No provider for entity_type={entity_type}")
return False
try:
text = await provider.get_embedding_text(db, entity_id, tenant_id)
if not text.strip():
logger.debug("Empty embedding text for %s/%s", entity_type, entity_id)
await _log_index("index", "skipped_empty")
return False
# Apply sensitive-data filter: ensure no sensitive fields leak into
@@ -170,34 +238,28 @@ async def index_entity(
flags=re.IGNORECASE,
)
embedding = await generate_embedding(text, db=db, tenant_id=tenant_id)
if not embedding:
return False
try:
embedding = await generate_embedding(text, db=db, tenant_id=tenant_id)
if not embedding:
await _log_index("index", "failed", "Empty embedding returned")
return False
# Update the entity's embedding column
from sqlalchemy import text as sql_text
table_map = {
"contact": "contacts",
"mail": "mails",
"file": "files",
"event": "calendar_entries",
}
table = table_map.get(entity_type)
if not table:
logger.warning("Unknown entity_type=%s for embedding storage", entity_type)
return False
sql = sql_text(
f"UPDATE {table} SET embedding = cast(:emb AS vector) "
f"WHERE id = :eid AND tenant_id = :tid"
)
await db.execute(
sql,
{"emb": str(embedding), "eid": entity_id, "tid": tenant_id},
)
await db.commit()
return True
# Update the entity's embedding column + indexed_at timestamp
sql = sql_text(
f"UPDATE {table} SET embedding = cast(:emb AS vector), indexed_at = now() "
f"WHERE id = :eid AND tenant_id = :tid"
)
await db.execute(
sql,
{"emb": str(embedding), "eid": entity_id, "tid": tenant_id},
)
await db.commit()
await _log_index("index", "success")
return True
except Exception as exc:
await db.rollback()
await _log_index("index", "failed", str(exc))
raise
except Exception:
logger.warning("Failed to index entity %s/%s", entity_type, entity_id, exc_info=True)
return False