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

266 lines
9.1 KiB
Python

"""Embedding pipeline using LiteLLM with OpenRouter for embeddings.
Delegates credential lookup, model building, and embedding generation to
the centralised ``app.ai.llm_client`` module. The wrapper functions here
preserve backward compatibility for existing call sites.
"""
from __future__ import annotations
import logging
import os
import uuid
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.llm_client import (
EMBEDDING_DIMENSIONS,
MAX_INPUT_CHARS,
OPENROUTER_EMBEDDING_MODEL,
build_model as _central_build_model,
get_api_credentials as _central_get_api_credentials,
llm_embed,
)
logger = logging.getLogger(__name__)
# Re-export constants for backward compatibility
__all__ = [
"MAX_INPUT_CHARS",
"OPENROUTER_API_KEY",
"OPENROUTER_BASE_URL",
"OPENROUTER_EMBEDDING_MODEL",
"EMBEDDING_DIMENSIONS",
"_get_api_credentials",
"_build_model",
"generate_embedding",
"generate_embeddings_batch",
"index_entity",
]
# Re-export for backward compatibility (consumers may import these directly)
OPENROUTER_API_KEY = os.environ.get("API_KEY_OPENROUTER", "")
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
async def _get_api_credentials(
db: "AsyncSession | None", tenant_id: "uuid.UUID | None"
) -> tuple[str | None, str | None, str | None]:
"""Get API key, base_url and provider_type for embeddings.
Thin wrapper delegating to ``app.ai.llm_client.get_api_credentials``.
Kept for backward compatibility with existing call sites.
"""
return await _central_get_api_credentials(db, tenant_id)
def _build_model(model: str, provider_type: str | None) -> str:
"""Build litellm model string with provider prefix.
Thin wrapper delegating to ``app.ai.llm_client.build_model``.
Kept for backward compatibility with existing call sites.
"""
return _central_build_model(model, provider_type)
async def generate_embedding(
text: str,
model: str | None = None,
db: "AsyncSession | None" = None,
tenant_id: "uuid.UUID | None" = None,
) -> list[float]:
"""Generate a single embedding via LiteLLM.
Uses OpenRouter with text-embedding-3-small (768 dimensions).
Args:
text: Input text (truncated to 8000 chars).
model: Embedding model name (default: openai/text-embedding-3-small).
db: Optional DB session for API key lookup.
tenant_id: Optional tenant ID for API key lookup.
Returns:
Embedding vector as list of floats.
"""
embeddings = await llm_embed(
texts=text,
model=model,
db=db,
tenant_id=tenant_id,
)
if embeddings and embeddings[0]:
return embeddings[0]
return []
async def generate_embeddings_batch(
texts: list[str],
model: str | None = None,
db: "AsyncSession | None" = None,
tenant_id: "uuid.UUID | None" = None,
) -> list[list[float]]:
"""Generate embeddings for multiple texts in a single API call.
Args:
texts: List of input texts.
model: Embedding model name (default: openai/text-embedding-3-small).
db: Optional DB session for API key lookup.
tenant_id: Optional tenant ID for API key lookup.
Returns:
List of embedding vectors.
"""
return await llm_embed(
texts=texts,
model=model,
db=db,
tenant_id=tenant_id,
)
async def index_entity(
entity_type: str,
entity_id: uuid.UUID,
tenant_id: uuid.UUID,
db: "AsyncSession",
) -> bool:
"""Generate and store embedding for a single entity.
Uses the provider registry to get embedding text, generates embedding,
and updates the entity's embedding column.
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
# embedding text. The provider builds text from DB columns, so we
# rely on the provider selecting only non-sensitive columns. This
# is a secondary safety net — providers should use filter_for_embeddings
# when constructing embedding text from dict-like data.
from app.core.sensitive_data import get_sensitive_fields
sensitive = get_sensitive_fields(entity_type)
if sensitive:
# If any sensitive field name appears as a substring in the text,
# it's likely a key=value pair — redact it. This is a best-effort
# guard; providers are expected to exclude sensitive columns at
# the SQL level.
for field_name in sensitive:
# Only redact if the field name appears as a key-like pattern
import re
text = re.sub(
rf"\b{re.escape(field_name)}\s*[=:]\s*\S+",
f"{field_name}=***REDACTED***",
text,
flags=re.IGNORECASE,
)
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 + 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