feat(E): Unified Search — 24 Tasks complete
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user