Files
leocrm/app/plugins/builtins/unified_search/embedding.py
T
Agent Zero abbe7a18fc fix(audit): P0-P3 audit fixes — 838 ruff errors → 0, 30 F821 bugs fixed, 118 files changed
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner
- P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var
- P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup
- P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns
- P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs)
- P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import
- P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default
- P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed
- P2: 28 frontend TODOs (hardcoded constants, deprecated notification API)
- P3: dead code, duplicates, deprecated imports, private attr, __import__ inline
- P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n)
- ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix)
- F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String)
- Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
2026-08-16 01:17:18 +02:00

271 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,
llm_embed,
)
from app.ai.llm_client import (
build_model as _central_build_model,
)
from app.ai.llm_client import (
get_api_credentials as _central_get_api_credentials,
)
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