feat: unified_search + ai_proactive plugins with Ollama Cloud DeepSeek V4
- unified_search: Hybride Suche (PostgreSQL FTS + pgvector + RRF Fusion) - 5 Search Providers (Contact, Company, Mail, File, Event) - KI Query Understanding (Fuzzy, Facetten via LiteLLM) - DMS Text-Extraction (PDF, DOCX, XLSX, PPTX) - Embedding Pipeline (ollama/nomic-embed-text, 768 Dim) - Background Jobs für Indexierung - Plugin-basierte Provider Registry - ai_proactive: Proaktiver KI-Agent - Context-Tracking (Frontend → Backend → Event Bus) - Proactive Engine mit LLM Suggestion-Generierung - SSE Real-time Push an Frontend - 6 AI Tools für Tool Registry - Rate-Limiting + User Settings - Deep Analysis Background Jobs - Frontend Integration: - useAIContext Hook, SuggestionSidebar, SuggestionBadge - ProactiveAISettings Page, Search API Client - Globale Suche auf neue API umgestellt - Tests: test_unified_search.py + test_ai_proactive.py (alle bestanden) - Config: Ollama Cloud DeepSeek V4 als Default, konfigurierbar - Dependencies: PyMuPDF, python-docx, python-pptx, pgvector - Bugfixes: notification type_key length, migration IF NOT EXISTS
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"""Unified Search plugin — hybrid FTS + semantic search across all CRM data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.builtins.unified_search.plugin import UnifiedSearchPlugin
|
||||
|
||||
__all__ = ["UnifiedSearchPlugin"]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Embedding pipeline using LiteLLM with configurable Ollama Cloud provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import litellm
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_INPUT_CHARS = 8000
|
||||
|
||||
DEFAULT_EMBEDDING_MODEL = os.environ.get('SEARCH_EMBEDDING_MODEL', 'ollama/nomic-embed-text')
|
||||
OLLAMA_API_KEY = os.environ.get('API_KEY_OLLAMA_CLOUD', '')
|
||||
|
||||
|
||||
async def generate_embedding(text: str, model: str | None = None) -> list[float]:
|
||||
"""Generate a single embedding via LiteLLM.
|
||||
|
||||
Args:
|
||||
text: Input text (truncated to 8000 chars).
|
||||
model: Embedding model name (default: ollama/nomic-embed-text).
|
||||
|
||||
Returns:
|
||||
Embedding vector as list of floats.
|
||||
"""
|
||||
model = model or DEFAULT_EMBEDDING_MODEL
|
||||
truncated = text[:MAX_INPUT_CHARS]
|
||||
try:
|
||||
response = await litellm.aembedding(
|
||||
model=model,
|
||||
input=truncated,
|
||||
api_key=OLLAMA_API_KEY,
|
||||
)
|
||||
return response.data[0]["embedding"]
|
||||
except Exception:
|
||||
logger.exception("Failed to generate embedding")
|
||||
return []
|
||||
|
||||
|
||||
async def generate_embeddings_batch(
|
||||
texts: list[str], model: str | 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: ollama/nomic-embed-text).
|
||||
|
||||
Returns:
|
||||
List of embedding vectors.
|
||||
"""
|
||||
model = model or DEFAULT_EMBEDDING_MODEL
|
||||
truncated = [t[:MAX_INPUT_CHARS] for t in texts]
|
||||
try:
|
||||
response = await litellm.aembedding(
|
||||
model=model,
|
||||
input=truncated,
|
||||
api_key=OLLAMA_API_KEY,
|
||||
)
|
||||
return [d["embedding"] for d in response.data]
|
||||
except Exception:
|
||||
logger.exception("Failed to generate batch embeddings")
|
||||
return [[] for _ in texts]
|
||||
|
||||
|
||||
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 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)
|
||||
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)
|
||||
return False
|
||||
|
||||
embedding = await generate_embedding(text)
|
||||
if not embedding:
|
||||
return False
|
||||
|
||||
# Update the entity's embedding column
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
table_map = {
|
||||
"contact": "contacts",
|
||||
"company": "companies",
|
||||
"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
|
||||
except Exception:
|
||||
logger.exception("Failed to index entity %s/%s", entity_type, entity_id)
|
||||
return False
|
||||
@@ -0,0 +1,246 @@
|
||||
"""ARQ background jobs for the Unified Search plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BATCH_SIZE = 100
|
||||
|
||||
|
||||
def _parse_id(id_str: str) -> uuid.UUID:
|
||||
"""Parse a string to UUID."""
|
||||
if isinstance(id_str, uuid.UUID):
|
||||
return id_str
|
||||
return uuid.UUID(str(id_str))
|
||||
|
||||
|
||||
async def index_mails(ctx: dict[str, Any], mail_ids: list[str]) -> None:
|
||||
"""Index mails: generate and store embeddings."""
|
||||
from app.plugins.builtins.unified_search.embedding import index_entity
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as db:
|
||||
for mail_id in mail_ids:
|
||||
try:
|
||||
eid = _parse_id(mail_id)
|
||||
# tenant_id is derived from the mail itself
|
||||
from sqlalchemy import text
|
||||
result = await db.execute(
|
||||
text("SELECT tenant_id FROM mails WHERE id = :mid"),
|
||||
{"mid": eid},
|
||||
)
|
||||
row = result.mappings().first()
|
||||
if not row:
|
||||
continue
|
||||
tenant_id = row["tenant_id"]
|
||||
await index_entity("mail", eid, tenant_id, db)
|
||||
except Exception:
|
||||
logger.exception("Failed to index mail %s", mail_id)
|
||||
|
||||
|
||||
async def index_file(ctx: dict[str, Any], file_id: str) -> None:
|
||||
"""Index a file: extract text, store content_text, generate embedding."""
|
||||
from sqlalchemy import text
|
||||
from app.plugins.builtins.unified_search.text_extraction import extract_text_from_file
|
||||
from app.plugins.builtins.unified_search.embedding import generate_embedding
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as db:
|
||||
try:
|
||||
eid = _parse_id(file_id)
|
||||
result = await db.execute(
|
||||
text("SELECT tenant_id, storage_path, mime_type FROM files WHERE id = :fid"),
|
||||
{"fid": eid},
|
||||
)
|
||||
row = result.mappings().first()
|
||||
if not row:
|
||||
logger.warning("File not found: %s", file_id)
|
||||
return
|
||||
|
||||
tenant_id = row["tenant_id"]
|
||||
storage_path = row["storage_path"]
|
||||
mime_type = row["mime_type"]
|
||||
|
||||
# Extract text
|
||||
content_text = await extract_text_from_file(storage_path, mime_type)
|
||||
|
||||
# Store content_text
|
||||
await db.execute(
|
||||
text("UPDATE files SET content_text = :ct WHERE id = :fid"),
|
||||
{"ct": content_text, "fid": eid},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Generate embedding from extracted text + filename
|
||||
result_name = await db.execute(
|
||||
text("SELECT name FROM files WHERE id = :fid"),
|
||||
{"fid": eid},
|
||||
)
|
||||
name_row = result_name.mappings().first()
|
||||
name = name_row["name"] if name_row else ""
|
||||
embedding_text = f"{name} {content_text[:5000]}"
|
||||
|
||||
if embedding_text.strip():
|
||||
embedding = await generate_embedding(embedding_text)
|
||||
if embedding:
|
||||
await db.execute(
|
||||
text("UPDATE files SET embedding = cast(:emb AS vector) WHERE id = :fid"),
|
||||
{"emb": str(embedding), "fid": eid},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info("Indexed file %s", file_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to index file %s", file_id)
|
||||
|
||||
|
||||
async def index_contact(ctx: dict[str, Any], contact_id: str) -> None:
|
||||
"""Index a contact: generate and store embedding."""
|
||||
from sqlalchemy import text
|
||||
from app.plugins.builtins.unified_search.embedding import index_entity
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as db:
|
||||
try:
|
||||
eid = _parse_id(contact_id)
|
||||
result = await db.execute(
|
||||
text("SELECT tenant_id FROM contacts WHERE id = :cid"),
|
||||
{"cid": eid},
|
||||
)
|
||||
row = result.mappings().first()
|
||||
if not row:
|
||||
return
|
||||
await index_entity("contact", eid, row["tenant_id"], db)
|
||||
except Exception:
|
||||
logger.exception("Failed to index contact %s", contact_id)
|
||||
|
||||
|
||||
async def index_company(ctx: dict[str, Any], company_id: str) -> None:
|
||||
"""Index a company: generate and store embedding."""
|
||||
from sqlalchemy import text
|
||||
from app.plugins.builtins.unified_search.embedding import index_entity
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as db:
|
||||
try:
|
||||
eid = _parse_id(company_id)
|
||||
result = await db.execute(
|
||||
text("SELECT tenant_id FROM companies WHERE id = :cid"),
|
||||
{"cid": eid},
|
||||
)
|
||||
row = result.mappings().first()
|
||||
if not row:
|
||||
return
|
||||
await index_entity("company", eid, row["tenant_id"], db)
|
||||
except Exception:
|
||||
logger.exception("Failed to index company %s", company_id)
|
||||
|
||||
|
||||
async def index_event(ctx: dict[str, Any], event_id: str) -> None:
|
||||
"""Index a calendar event: generate and store embedding."""
|
||||
from sqlalchemy import text
|
||||
from app.plugins.builtins.unified_search.embedding import index_entity
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as db:
|
||||
try:
|
||||
eid = _parse_id(event_id)
|
||||
result = await db.execute(
|
||||
text("SELECT tenant_id FROM calendar_entries WHERE id = :eid"),
|
||||
{"eid": eid},
|
||||
)
|
||||
row = result.mappings().first()
|
||||
if not row:
|
||||
return
|
||||
await index_entity("event", eid, row["tenant_id"], db)
|
||||
except Exception:
|
||||
logger.exception("Failed to index event %s", event_id)
|
||||
|
||||
|
||||
async def reindex(ctx: dict[str, Any], entity_type: str) -> None:
|
||||
"""Reindex all entities of a given type with pagination."""
|
||||
from sqlalchemy import text
|
||||
from app.plugins.builtins.unified_search.embedding import index_entity
|
||||
|
||||
table_map = {
|
||||
"contact": "contacts",
|
||||
"company": "companies",
|
||||
"mail": "mails",
|
||||
"file": "files",
|
||||
"event": "calendar_entries",
|
||||
}
|
||||
table = table_map.get(entity_type)
|
||||
if not table:
|
||||
logger.warning("Unknown entity_type for reindex: %s", entity_type)
|
||||
return
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as db:
|
||||
offset = 0
|
||||
while True:
|
||||
result = await db.execute(
|
||||
text(
|
||||
f"SELECT id, tenant_id FROM {table} "
|
||||
f"WHERE deleted_at IS NULL ORDER BY created_at LIMIT :lim OFFSET :off"
|
||||
),
|
||||
{"lim": BATCH_SIZE, "off": offset},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
if not rows:
|
||||
break
|
||||
for row in rows:
|
||||
try:
|
||||
await index_entity(
|
||||
entity_type,
|
||||
row["id"],
|
||||
row["tenant_id"],
|
||||
db,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Reindex failed for %s/%s", entity_type, row["id"])
|
||||
offset += BATCH_SIZE
|
||||
|
||||
logger.info("Reindex complete for %s", entity_type)
|
||||
|
||||
|
||||
async def embedding_batch(ctx: dict[str, Any]) -> None:
|
||||
"""Periodic job: find entities without embeddings and index them."""
|
||||
from sqlalchemy import text
|
||||
from app.plugins.builtins.unified_search.embedding import index_entity
|
||||
|
||||
table_map = {
|
||||
"contact": "contacts",
|
||||
"company": "companies",
|
||||
"mail": "mails",
|
||||
"file": "files",
|
||||
"event": "calendar_entries",
|
||||
}
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as db:
|
||||
for etype, table in table_map.items():
|
||||
try:
|
||||
result = await db.execute(
|
||||
text(
|
||||
f"SELECT id, tenant_id FROM {table} "
|
||||
f"WHERE deleted_at IS NULL AND embedding IS NULL "
|
||||
f"LIMIT :lim"
|
||||
),
|
||||
{"lim": BATCH_SIZE},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
for row in rows:
|
||||
try:
|
||||
await index_entity(etype, row["id"], row["tenant_id"], db)
|
||||
except Exception:
|
||||
logger.exception("Batch index failed for %s/%s", etype, row["id"])
|
||||
except Exception:
|
||||
logger.exception("Batch query failed for %s", etype)
|
||||
|
||||
logger.info("Embedding batch job complete")
|
||||
@@ -0,0 +1,154 @@
|
||||
-- Unified Search: pgvector extension, FTS triggers, provider registry
|
||||
|
||||
-- pgvector extension
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
-- ─── Provider Registry Table ───
|
||||
CREATE TABLE IF NOT EXISTS unified_search_providers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
plugin_name VARCHAR(80) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(tenant_id, entity_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_usp_tenant ON unified_search_providers(tenant_id);
|
||||
|
||||
-- ─── Index Log Table ───
|
||||
CREATE TABLE IF NOT EXISTS unified_search_index_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
action VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_usil_tenant ON unified_search_index_log(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_usil_entity ON unified_search_index_log(entity_type, entity_id);
|
||||
|
||||
-- ─── FTS: Mails ───
|
||||
ALTER TABLE mails ADD COLUMN IF NOT EXISTS body_tsv tsvector;
|
||||
|
||||
CREATE OR REPLACE FUNCTION mails_tsv_trigger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.body_tsv :=
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.subject, '')), 'A') ||
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.from_address, '')), 'B') ||
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.to_addresses, '')), 'C') ||
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.body_text, '')), 'D');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS mails_tsv_update ON mails;
|
||||
CREATE TRIGGER mails_tsv_update
|
||||
BEFORE INSERT OR UPDATE ON mails
|
||||
FOR EACH ROW EXECUTE FUNCTION mails_tsv_trigger();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_mails_body_tsv ON mails USING gin(body_tsv);
|
||||
|
||||
-- ─── FTS: Contacts ───
|
||||
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS search_tsv tsvector;
|
||||
|
||||
CREATE OR REPLACE FUNCTION contacts_tsv_trigger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.search_tsv :=
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.first_name, '') || ' ' || coalesce(NEW.last_name, '')), 'A') ||
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.email, '')), 'B') ||
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.phone, '') || ' ' || coalesce(NEW.mobile, '')), 'C') ||
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.notes, '')), 'D');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS contacts_tsv_update ON contacts;
|
||||
CREATE TRIGGER contacts_tsv_update
|
||||
BEFORE INSERT OR UPDATE ON contacts
|
||||
FOR EACH ROW EXECUTE FUNCTION contacts_tsv_trigger();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_contacts_search_tsv ON contacts USING gin(search_tsv);
|
||||
|
||||
-- ─── FTS: Calendar Entries ───
|
||||
ALTER TABLE calendar_entries ADD COLUMN IF NOT EXISTS search_tsv tsvector;
|
||||
|
||||
CREATE OR REPLACE FUNCTION calendar_entries_tsv_trigger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.search_tsv :=
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.title, '')), 'A') ||
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.description, '')), 'B') ||
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.location, '')), 'C');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS calendar_entries_tsv_update ON calendar_entries;
|
||||
CREATE TRIGGER calendar_entries_tsv_update
|
||||
BEFORE INSERT OR UPDATE ON calendar_entries
|
||||
FOR EACH ROW EXECUTE FUNCTION calendar_entries_tsv_trigger();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_cal_entries_search_tsv ON calendar_entries USING gin(search_tsv);
|
||||
|
||||
-- ─── FTS: Files (content_text added here, not in 0002, to avoid trigger dependency) ───
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS content_text text;
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS content_tsv tsvector;
|
||||
|
||||
CREATE OR REPLACE FUNCTION files_tsv_trigger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.content_tsv :=
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.name, '')), 'A') ||
|
||||
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.content_text, '')), 'D');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS files_tsv_update ON files;
|
||||
CREATE TRIGGER files_tsv_update
|
||||
BEFORE INSERT OR UPDATE ON files
|
||||
FOR EACH ROW EXECUTE FUNCTION files_tsv_trigger();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_files_content_tsv ON files USING gin(content_tsv);
|
||||
|
||||
-- ─── FTS: Tags ───
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS search_tsv tsvector;
|
||||
|
||||
CREATE OR REPLACE FUNCTION tags_tsv_trigger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.search_tsv := to_tsvector('pg_catalog.german', coalesce(NEW.name, ''));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS tags_tsv_update ON tags;
|
||||
CREATE TRIGGER tags_tsv_update
|
||||
BEFORE INSERT OR UPDATE ON tags
|
||||
FOR EACH ROW EXECUTE FUNCTION tags_tsv_trigger();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_tags_search_tsv ON tags USING gin(search_tsv);
|
||||
|
||||
-- ─── FTS: Audit Log ───
|
||||
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS search_tsv tsvector;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit_log_tsv_trigger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.search_tsv :=
|
||||
to_tsvector('pg_catalog.german',
|
||||
coalesce(NEW.entity_type, '') || ' ' ||
|
||||
coalesce(NEW.action, '') || ' ' ||
|
||||
coalesce(NEW.user_id::text, ''));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS audit_log_tsv_update ON audit_log;
|
||||
CREATE TRIGGER audit_log_tsv_update
|
||||
BEFORE INSERT OR UPDATE ON audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_log_tsv_trigger();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_log_search_tsv ON audit_log USING gin(search_tsv);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Unified Search: Embedding columns and HNSW indexes
|
||||
|
||||
-- ─── Mails ───
|
||||
ALTER TABLE mails ADD COLUMN IF NOT EXISTS embedding vector(768);
|
||||
CREATE INDEX IF NOT EXISTS ix_mails_embedding ON mails USING hnsw(embedding vector_cosine_ops);
|
||||
|
||||
-- ─── Contacts ───
|
||||
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS embedding vector(768);
|
||||
CREATE INDEX IF NOT EXISTS ix_contacts_embedding ON contacts USING hnsw(embedding vector_cosine_ops);
|
||||
|
||||
-- ─── Companies ───
|
||||
ALTER TABLE companies ADD COLUMN IF NOT EXISTS embedding vector(768);
|
||||
CREATE INDEX IF NOT EXISTS ix_companies_embedding ON companies USING hnsw(embedding vector_cosine_ops);
|
||||
|
||||
-- ─── Files (content_text already added in 0001) ───
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS embedding vector(768);
|
||||
CREATE INDEX IF NOT EXISTS ix_files_embedding ON files USING hnsw(embedding vector_cosine_ops);
|
||||
|
||||
-- ─── Calendar Entries ───
|
||||
ALTER TABLE calendar_entries ADD COLUMN IF NOT EXISTS embedding vector(768);
|
||||
CREATE INDEX IF NOT EXISTS ix_cal_embedding ON calendar_entries USING hnsw(embedding vector_cosine_ops);
|
||||
|
||||
-- ─── Tags (shorter dimension for short text) ───
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS embedding vector(384);
|
||||
@@ -0,0 +1,50 @@
|
||||
"""SQLAlchemy models for the Unified Search plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class SearchProviderRegistry(Base, TenantMixin):
|
||||
"""Registry of active search providers per tenant."""
|
||||
|
||||
__tablename__ = "unified_search_providers"
|
||||
__table_args__ = (
|
||||
Index("ix_usp_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
plugin_name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
nullable=False, default=True
|
||||
)
|
||||
config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
|
||||
|
||||
class SearchIndexLog(Base, TenantMixin):
|
||||
"""Log of indexing actions for audit and debugging."""
|
||||
|
||||
__tablename__ = "unified_search_index_log"
|
||||
__table_args__ = (
|
||||
Index("ix_usil_tenant", "tenant_id"),
|
||||
Index("ix_usil_entity", "entity_type", "entity_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Unified Search plugin class and manifest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnifiedSearchPlugin(BasePlugin):
|
||||
"""Hybrid full-text and semantic search across all CRM entities."""
|
||||
|
||||
manifest = PluginManifest(
|
||||
name="unified_search",
|
||||
version="1.0.0",
|
||||
display_name="Unified Search",
|
||||
description=(
|
||||
"Hybrid full-text (PostgreSQL FTS) and semantic (pgvector) search "
|
||||
"with KI query understanding and RRF rank fusion across all CRM data."
|
||||
),
|
||||
dependencies=[],
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
path="/api/v1/search",
|
||||
module="app.plugins.builtins.unified_search.routes",
|
||||
router_attr="router",
|
||||
),
|
||||
],
|
||||
events=[
|
||||
"mail.synced",
|
||||
"file.uploaded",
|
||||
"contact.created",
|
||||
"contact.updated",
|
||||
"company.created",
|
||||
"company.updated",
|
||||
"calendar.entry.created",
|
||||
],
|
||||
migrations=["0001_initial.sql", "0002_embeddings.sql"],
|
||||
permissions=["search:read", "search:admin"],
|
||||
is_core=False,
|
||||
)
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
"""Register search providers on activation."""
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
try:
|
||||
from app.plugins.builtins.unified_search.provider_registry import (
|
||||
auto_register_providers,
|
||||
)
|
||||
await auto_register_providers(db)
|
||||
logger.info("Unified Search providers auto-registered")
|
||||
except Exception:
|
||||
logger.exception("Failed to auto-register search providers")
|
||||
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
"""Clear provider registry on deactivation."""
|
||||
from app.plugins.builtins.unified_search.provider_registry import (
|
||||
get_search_registry,
|
||||
)
|
||||
registry = get_search_registry()
|
||||
registry.clear()
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
|
||||
# ─── Event Handlers ───
|
||||
|
||||
async def on_mail_synced(self, payload: dict[str, Any]) -> None:
|
||||
"""Enqueue embedding jobs for synced mails."""
|
||||
from app.core.jobs import enqueue_job
|
||||
|
||||
mail_ids = payload.get("mail_ids", [])
|
||||
if mail_ids:
|
||||
await enqueue_job("index_mails", mail_ids)
|
||||
|
||||
async def on_file_uploaded(self, payload: dict[str, Any]) -> None:
|
||||
"""Enqueue file indexing job."""
|
||||
from app.core.jobs import enqueue_job
|
||||
|
||||
file_id = payload.get("file_id")
|
||||
if file_id:
|
||||
await enqueue_job("index_file", file_id)
|
||||
|
||||
async def on_contact_created(self, payload: dict[str, Any]) -> None:
|
||||
from app.core.jobs import enqueue_job
|
||||
|
||||
contact_id = payload.get("contact_id")
|
||||
if contact_id:
|
||||
await enqueue_job("index_contact", contact_id)
|
||||
|
||||
async def on_contact_updated(self, payload: dict[str, Any]) -> None:
|
||||
from app.core.jobs import enqueue_job
|
||||
|
||||
contact_id = payload.get("contact_id")
|
||||
if contact_id:
|
||||
await enqueue_job("index_contact", contact_id)
|
||||
|
||||
async def on_company_created(self, payload: dict[str, Any]) -> None:
|
||||
from app.core.jobs import enqueue_job
|
||||
|
||||
company_id = payload.get("company_id")
|
||||
if company_id:
|
||||
await enqueue_job("index_company", company_id)
|
||||
|
||||
async def on_company_updated(self, payload: dict[str, Any]) -> None:
|
||||
from app.core.jobs import enqueue_job
|
||||
|
||||
company_id = payload.get("company_id")
|
||||
if company_id:
|
||||
await enqueue_job("index_company", company_id)
|
||||
|
||||
async def on_calendar_entry_created(self, payload: dict[str, Any]) -> None:
|
||||
from app.core.jobs import enqueue_job
|
||||
|
||||
entry_id = payload.get("entry_id")
|
||||
if entry_id:
|
||||
await enqueue_job("index_event", entry_id)
|
||||
|
||||
def get_notification_types(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"type_key": "search_error",
|
||||
"category": "search",
|
||||
"label": "Suchfehler",
|
||||
"description": "Fehler bei der Suchausführung",
|
||||
"is_enabled_by_default": True,
|
||||
},
|
||||
{
|
||||
"type_key": "search_reindex_complete",
|
||||
"category": "search",
|
||||
"label": "Reindex abgeschlossen",
|
||||
"description": "Neuindizierung abgeschlossen",
|
||||
"is_enabled_by_default": False,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,133 @@
|
||||
"""SearchProvider protocol and global registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import uuid
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SearchProvider(Protocol):
|
||||
"""Protocol for entity-specific search providers."""
|
||||
|
||||
entity_type: str
|
||||
|
||||
async def search_fts(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tsquery: str,
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> list[dict]:
|
||||
"""Full-text search using PostgreSQL tsquery."""
|
||||
...
|
||||
|
||||
async def search_vector(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
embedding: list[float],
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> list[dict]:
|
||||
"""Semantic vector search using pgvector."""
|
||||
...
|
||||
|
||||
async def get_embedding_text(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
entity_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> str:
|
||||
"""Get text representation for embedding generation."""
|
||||
...
|
||||
|
||||
def to_search_result(self, entity: object) -> dict:
|
||||
"""Convert an ORM entity to a search result dict."""
|
||||
...
|
||||
|
||||
|
||||
class SearchProviderRegistry:
|
||||
"""In-memory registry of search providers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._providers: dict[str, SearchProvider] = {}
|
||||
|
||||
def register(self, provider: SearchProvider) -> None:
|
||||
"""Register a search provider."""
|
||||
self._providers[provider.entity_type] = provider
|
||||
logger.debug("Registered search provider: %s", provider.entity_type)
|
||||
|
||||
def unregister(self, entity_type: str) -> None:
|
||||
"""Unregister a search provider by entity type."""
|
||||
self._providers.pop(entity_type, None)
|
||||
|
||||
def get(self, entity_type: str) -> SearchProvider | None:
|
||||
"""Get a provider by entity type."""
|
||||
return self._providers.get(entity_type)
|
||||
|
||||
def get_all(self) -> list[SearchProvider]:
|
||||
"""Get all registered providers."""
|
||||
return list(self._providers.values())
|
||||
|
||||
def get_entity_types(self) -> list[str]:
|
||||
"""Get all registered entity types."""
|
||||
return list(self._providers.keys())
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all registered providers."""
|
||||
self._providers.clear()
|
||||
|
||||
|
||||
# Global singleton
|
||||
_registry = SearchProviderRegistry()
|
||||
|
||||
|
||||
def get_search_registry() -> SearchProviderRegistry:
|
||||
"""Get the global search provider registry."""
|
||||
return _registry
|
||||
|
||||
|
||||
async def auto_register_providers(db: AsyncSession) -> None:
|
||||
"""Auto-register providers for active plugins.
|
||||
|
||||
Checks which plugins are active and registers corresponding providers.
|
||||
"""
|
||||
from app.plugins.builtins.unified_search.providers.contact_provider import (
|
||||
ContactSearchProvider,
|
||||
)
|
||||
from app.plugins.builtins.unified_search.providers.company_provider import (
|
||||
CompanySearchProvider,
|
||||
)
|
||||
from app.plugins.builtins.unified_search.providers.mail_provider import (
|
||||
MailSearchProvider,
|
||||
)
|
||||
from app.plugins.builtins.unified_search.providers.file_provider import (
|
||||
FileSearchProvider,
|
||||
)
|
||||
from app.plugins.builtins.unified_search.providers.event_provider import (
|
||||
EventSearchProvider,
|
||||
)
|
||||
|
||||
registry = get_search_registry()
|
||||
registry.clear()
|
||||
|
||||
# Register all built-in providers
|
||||
for provider_cls in [
|
||||
ContactSearchProvider,
|
||||
CompanySearchProvider,
|
||||
MailSearchProvider,
|
||||
FileSearchProvider,
|
||||
EventSearchProvider,
|
||||
]:
|
||||
try:
|
||||
registry.register(provider_cls())
|
||||
except Exception:
|
||||
logger.exception("Failed to register %s", provider_cls.__name__)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Search providers for unified search."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Company search provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CompanySearchProvider:
|
||||
"""Search provider for Company entities."""
|
||||
|
||||
entity_type = "company"
|
||||
|
||||
async def search_fts(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tsquery: str,
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Full-text search on companies.search_tsv."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT c.*, ts_rank(c.search_tsv, to_tsquery('pg_catalog.german', :q)) AS rank
|
||||
FROM companies c
|
||||
WHERE c.tenant_id = :tid
|
||||
AND c.deleted_at IS NULL
|
||||
AND c.search_tsv @@ to_tsquery('pg_catalog.german', :q)
|
||||
ORDER BY rank DESC
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
result = await db.execute(
|
||||
sql,
|
||||
{"q": tsquery, "tid": tenant_id, "lim": limit},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def search_vector(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
embedding: list[float],
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Semantic search on companies.embedding."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT c.*, 1 - (c.embedding <=> cast(:emb AS vector)) AS score
|
||||
FROM companies c
|
||||
WHERE c.tenant_id = :tid
|
||||
AND c.deleted_at IS NULL
|
||||
AND c.embedding IS NOT NULL
|
||||
ORDER BY c.embedding <=> cast(:emb AS vector)
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
result = await db.execute(
|
||||
sql,
|
||||
{"emb": str(embedding), "tid": tenant_id, "lim": limit},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def get_embedding_text(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
entity_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> str:
|
||||
"""Get text for embedding generation."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT name, description, industry
|
||||
FROM companies
|
||||
WHERE id = :eid AND tenant_id = :tid
|
||||
"""
|
||||
)
|
||||
result = await db.execute(sql, {"eid": entity_id, "tid": tenant_id})
|
||||
row = result.mappings().first()
|
||||
if not row:
|
||||
return ""
|
||||
parts = [
|
||||
row.get("name", ""),
|
||||
row.get("description", ""),
|
||||
row.get("industry", ""),
|
||||
]
|
||||
return " ".join(str(p) for p in parts if p)
|
||||
|
||||
def to_search_result(self, entity: object) -> dict[str, Any]:
|
||||
"""Convert company to search result dict."""
|
||||
if isinstance(entity, dict):
|
||||
name = entity.get("name", "")
|
||||
description = entity.get("description", "") or ""
|
||||
entity_id = str(entity.get("id", ""))
|
||||
else:
|
||||
name = getattr(entity, "name", "")
|
||||
description = getattr(entity, "description", "") or ""
|
||||
entity_id = str(getattr(entity, "id", ""))
|
||||
return {
|
||||
"entity_type": self.entity_type,
|
||||
"entity_id": entity_id,
|
||||
"title": name,
|
||||
"snippet": description[:200],
|
||||
"score": 0.0,
|
||||
"data": {},
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Contact search provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.contact import Contact
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContactSearchProvider:
|
||||
"""Search provider for Contact entities."""
|
||||
|
||||
entity_type = "contact"
|
||||
|
||||
async def search_fts(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tsquery: str,
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Full-text search on contacts.search_tsv."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT c.*, ts_rank(c.search_tsv, to_tsquery('pg_catalog.german', :q)) AS rank
|
||||
FROM contacts c
|
||||
WHERE c.tenant_id = :tid
|
||||
AND c.deleted_at IS NULL
|
||||
AND c.search_tsv @@ to_tsquery('pg_catalog.german', :q)
|
||||
ORDER BY rank DESC
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
result = await db.execute(
|
||||
sql,
|
||||
{"q": tsquery, "tid": tenant_id, "lim": limit},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def search_vector(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
embedding: list[float],
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Semantic search on contacts.embedding."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT c.*, 1 - (c.embedding <=> cast(:emb AS vector)) AS score
|
||||
FROM contacts c
|
||||
WHERE c.tenant_id = :tid
|
||||
AND c.deleted_at IS NULL
|
||||
AND c.embedding IS NOT NULL
|
||||
ORDER BY c.embedding <=> cast(:emb AS vector)
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
result = await db.execute(
|
||||
sql,
|
||||
{"emb": str(embedding), "tid": tenant_id, "lim": limit},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def get_embedding_text(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
entity_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> str:
|
||||
"""Get text for embedding generation."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT first_name, last_name, email, phone, mobile, notes
|
||||
FROM contacts
|
||||
WHERE id = :eid AND tenant_id = :tid
|
||||
"""
|
||||
)
|
||||
result = await db.execute(sql, {"eid": entity_id, "tid": tenant_id})
|
||||
row = result.mappings().first()
|
||||
if not row:
|
||||
return ""
|
||||
parts = [
|
||||
row.get("first_name", ""),
|
||||
row.get("last_name", ""),
|
||||
row.get("email", ""),
|
||||
row.get("phone", ""),
|
||||
row.get("mobile", ""),
|
||||
row.get("notes", ""),
|
||||
]
|
||||
return " ".join(str(p) for p in parts if p)
|
||||
|
||||
def to_search_result(self, entity: object) -> dict[str, Any]:
|
||||
"""Convert contact to search result dict."""
|
||||
if isinstance(entity, dict):
|
||||
first = entity.get("first_name", "")
|
||||
last = entity.get("last_name", "")
|
||||
email = entity.get("email", "")
|
||||
entity_id = str(entity.get("id", ""))
|
||||
else:
|
||||
first = getattr(entity, "first_name", "")
|
||||
last = getattr(entity, "last_name", "")
|
||||
email = getattr(entity, "email", "")
|
||||
entity_id = str(getattr(entity, "id", ""))
|
||||
return {
|
||||
"entity_type": self.entity_type,
|
||||
"entity_id": entity_id,
|
||||
"title": f"{first} {last}".strip(),
|
||||
"snippet": email or "",
|
||||
"score": 0.0,
|
||||
"data": {},
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Calendar event search provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventSearchProvider:
|
||||
"""Search provider for CalendarEntry entities."""
|
||||
|
||||
entity_type = "event"
|
||||
|
||||
async def search_fts(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tsquery: str,
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Full-text search on calendar_entries.search_tsv."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT e.*, ts_rank(e.search_tsv, to_tsquery('pg_catalog.german', :q)) AS rank
|
||||
FROM calendar_entries e
|
||||
WHERE e.tenant_id = :tid
|
||||
AND e.deleted_at IS NULL
|
||||
AND e.search_tsv @@ to_tsquery('pg_catalog.german', :q)
|
||||
ORDER BY rank DESC
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
result = await db.execute(
|
||||
sql,
|
||||
{"q": tsquery, "tid": tenant_id, "lim": limit},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def search_vector(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
embedding: list[float],
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Semantic search on calendar_entries.embedding."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT e.*, 1 - (e.embedding <=> cast(:emb AS vector)) AS score
|
||||
FROM calendar_entries e
|
||||
WHERE e.tenant_id = :tid
|
||||
AND e.deleted_at IS NULL
|
||||
AND e.embedding IS NOT NULL
|
||||
ORDER BY e.embedding <=> cast(:emb AS vector)
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
result = await db.execute(
|
||||
sql,
|
||||
{"emb": str(embedding), "tid": tenant_id, "lim": limit},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def get_embedding_text(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
entity_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> str:
|
||||
"""Get text for embedding generation."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT title, description, location
|
||||
FROM calendar_entries
|
||||
WHERE id = :eid AND tenant_id = :tid
|
||||
"""
|
||||
)
|
||||
result = await db.execute(sql, {"eid": entity_id, "tid": tenant_id})
|
||||
row = result.mappings().first()
|
||||
if not row:
|
||||
return ""
|
||||
parts = [
|
||||
row.get("title", ""),
|
||||
row.get("description", ""),
|
||||
row.get("location", ""),
|
||||
]
|
||||
return " ".join(str(p) for p in parts if p)
|
||||
|
||||
def to_search_result(self, entity: object) -> dict[str, Any]:
|
||||
"""Convert calendar entry to search result dict."""
|
||||
if isinstance(entity, dict):
|
||||
title = entity.get("title", "")
|
||||
description = entity.get("description", "") or ""
|
||||
entity_id = str(entity.get("id", ""))
|
||||
else:
|
||||
title = getattr(entity, "title", "")
|
||||
description = getattr(entity, "description", "") or ""
|
||||
entity_id = str(getattr(entity, "id", ""))
|
||||
return {
|
||||
"entity_type": self.entity_type,
|
||||
"entity_id": entity_id,
|
||||
"title": title,
|
||||
"snippet": description[:200],
|
||||
"score": 0.0,
|
||||
"data": {},
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"""DMS File search provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileSearchProvider:
|
||||
"""Search provider for DMS File entities."""
|
||||
|
||||
entity_type = "file"
|
||||
|
||||
async def search_fts(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tsquery: str,
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Full-text search on files.content_tsv."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT f.*, ts_rank(f.content_tsv, to_tsquery('pg_catalog.german', :q)) AS rank
|
||||
FROM files f
|
||||
WHERE f.tenant_id = :tid
|
||||
AND f.deleted_at IS NULL
|
||||
AND f.content_tsv @@ to_tsquery('pg_catalog.german', :q)
|
||||
ORDER BY rank DESC
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
result = await db.execute(
|
||||
sql,
|
||||
{"q": tsquery, "tid": tenant_id, "lim": limit},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def search_vector(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
embedding: list[float],
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Semantic search on files.embedding."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT f.*, 1 - (f.embedding <=> cast(:emb AS vector)) AS score
|
||||
FROM files f
|
||||
WHERE f.tenant_id = :tid
|
||||
AND f.deleted_at IS NULL
|
||||
AND f.embedding IS NOT NULL
|
||||
ORDER BY f.embedding <=> cast(:emb AS vector)
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
result = await db.execute(
|
||||
sql,
|
||||
{"emb": str(embedding), "tid": tenant_id, "lim": limit},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def get_embedding_text(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
entity_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> str:
|
||||
"""Get text for embedding generation."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT name, content_text
|
||||
FROM files
|
||||
WHERE id = :eid AND tenant_id = :tid
|
||||
"""
|
||||
)
|
||||
result = await db.execute(sql, {"eid": entity_id, "tid": tenant_id})
|
||||
row = result.mappings().first()
|
||||
if not row:
|
||||
return ""
|
||||
name = row.get("name", "") or ""
|
||||
content = row.get("content_text", "") or ""
|
||||
return f"{name} {content[:5000]}"
|
||||
|
||||
def to_search_result(self, entity: object) -> dict[str, Any]:
|
||||
"""Convert file to search result dict."""
|
||||
if isinstance(entity, dict):
|
||||
name = entity.get("name", "")
|
||||
content = entity.get("content_text", "") or ""
|
||||
entity_id = str(entity.get("id", ""))
|
||||
else:
|
||||
name = getattr(entity, "name", "")
|
||||
content = getattr(entity, "content_text", "") or ""
|
||||
entity_id = str(getattr(entity, "id", ""))
|
||||
return {
|
||||
"entity_type": self.entity_type,
|
||||
"entity_id": entity_id,
|
||||
"title": name,
|
||||
"snippet": content[:200],
|
||||
"score": 0.0,
|
||||
"data": {},
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Mail search provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MailSearchProvider:
|
||||
"""Search provider for Mail entities."""
|
||||
|
||||
entity_type = "mail"
|
||||
|
||||
async def search_fts(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tsquery: str,
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Full-text search on mails.body_tsv."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT m.*, ts_rank(m.body_tsv, to_tsquery('pg_catalog.german', :q)) AS rank
|
||||
FROM mails m
|
||||
WHERE m.tenant_id = :tid
|
||||
AND m.deleted_at IS NULL
|
||||
AND m.body_tsv @@ to_tsquery('pg_catalog.german', :q)
|
||||
ORDER BY rank DESC
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
result = await db.execute(
|
||||
sql,
|
||||
{"q": tsquery, "tid": tenant_id, "lim": limit},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def search_vector(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
embedding: list[float],
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Semantic search on mails.embedding."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT m.*, 1 - (m.embedding <=> cast(:emb AS vector)) AS score
|
||||
FROM mails m
|
||||
WHERE m.tenant_id = :tid
|
||||
AND m.deleted_at IS NULL
|
||||
AND m.embedding IS NOT NULL
|
||||
ORDER BY m.embedding <=> cast(:emb AS vector)
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
result = await db.execute(
|
||||
sql,
|
||||
{"emb": str(embedding), "tid": tenant_id, "lim": limit},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def get_embedding_text(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
entity_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> str:
|
||||
"""Get text for embedding generation."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT subject, body_text
|
||||
FROM mails
|
||||
WHERE id = :eid AND tenant_id = :tid
|
||||
"""
|
||||
)
|
||||
result = await db.execute(sql, {"eid": entity_id, "tid": tenant_id})
|
||||
row = result.mappings().first()
|
||||
if not row:
|
||||
return ""
|
||||
subject = row.get("subject", "") or ""
|
||||
body = row.get("body_text", "") or ""
|
||||
return f"{subject} {body[:5000]}"
|
||||
|
||||
def to_search_result(self, entity: object) -> dict[str, Any]:
|
||||
"""Convert mail to search result dict."""
|
||||
if isinstance(entity, dict):
|
||||
subject = entity.get("subject", "")
|
||||
body = entity.get("body_text", "") or ""
|
||||
entity_id = str(entity.get("id", ""))
|
||||
else:
|
||||
subject = getattr(entity, "subject", "")
|
||||
body = getattr(entity, "body_text", "") or ""
|
||||
entity_id = str(getattr(entity, "id", ""))
|
||||
return {
|
||||
"entity_type": self.entity_type,
|
||||
"entity_id": entity_id,
|
||||
"title": subject,
|
||||
"snippet": body[:200],
|
||||
"score": 0.0,
|
||||
"data": {},
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""KI query understanding and result aggregation via LiteLLM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import litellm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_LLM_MODEL = os.environ.get('SEARCH_LLM_MODEL', 'ollama/deepseek-v4')
|
||||
OLLAMA_API_KEY = os.environ.get('API_KEY_OLLAMA_CLOUD', '')
|
||||
|
||||
QUERY_ANALYZE_SYSTEM = (
|
||||
"Du bist ein Query-Analyzer fuer ein CRM. "
|
||||
"Analysiere die Suchanfrage und gib JSON zurueck: "
|
||||
'{"normalized_query": str, "entities": {"person": str|null, "company": str|null, "topic": str|null}, '
|
||||
'"intent": str, "semantic_terms": [str], "suggested_filters": {}}'
|
||||
)
|
||||
|
||||
RESULT_AGGREGATE_SYSTEM = (
|
||||
"Du bist ein Result-Aggregator. Fasse Ergebnisse zusammen und generiere Facetten: "
|
||||
'{"summary": str, "facets": {"types": {}, "dates": {}, "people": []}, "suggestions": [str]}'
|
||||
)
|
||||
|
||||
|
||||
def _fallback_query_analysis(query: str) -> dict[str, Any]:
|
||||
return {
|
||||
"normalized_query": query,
|
||||
"entities": {},
|
||||
"intent": "search",
|
||||
"semantic_terms": [],
|
||||
"suggested_filters": {},
|
||||
}
|
||||
|
||||
|
||||
def _fallback_aggregate(results: list[dict], query: str) -> dict[str, Any]:
|
||||
return {
|
||||
"summary": f"{len(results)} Ergebnisse gefunden",
|
||||
"facets": {},
|
||||
"suggestions": [],
|
||||
}
|
||||
|
||||
|
||||
async def llm_analyze_query(query: str) -> dict[str, Any]:
|
||||
"""Analyze a search query using LLM for intent, entities, and semantic terms.
|
||||
|
||||
Falls back to a simple dict if LLM fails.
|
||||
"""
|
||||
try:
|
||||
response = await litellm.acompletion(
|
||||
model=DEFAULT_LLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": QUERY_ANALYZE_SYSTEM},
|
||||
{"role": "user", "content": query},
|
||||
],
|
||||
temperature=0.1,
|
||||
max_tokens=500,
|
||||
response_format={"type": "json_object"},
|
||||
api_key=OLLAMA_API_KEY,
|
||||
)
|
||||
content = response.choices[0].message.content
|
||||
return json.loads(content)
|
||||
except Exception:
|
||||
logger.warning("LLM query analysis failed, using fallback")
|
||||
return _fallback_query_analysis(query)
|
||||
|
||||
|
||||
async def llm_aggregate_results(results: list[dict], query: str) -> dict[str, Any]:
|
||||
"""Aggregate search results using LLM for summary, facets, and suggestions.
|
||||
|
||||
Falls back to a simple dict if LLM fails.
|
||||
"""
|
||||
if not results:
|
||||
return _fallback_aggregate(results, query)
|
||||
try:
|
||||
# Truncate results to avoid token overflow
|
||||
compact = [
|
||||
{"entity_type": r.get("entity_type"), "title": r.get("title", "")[:100]}
|
||||
for r in results[:50]
|
||||
]
|
||||
user_msg = json.dumps({"query": query, "results": compact})
|
||||
response = await litellm.acompletion(
|
||||
model=DEFAULT_LLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": RESULT_AGGREGATE_SYSTEM},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
temperature=0.1,
|
||||
max_tokens=1000,
|
||||
response_format={"type": "json_object"},
|
||||
api_key=OLLAMA_API_KEY,
|
||||
)
|
||||
content = response.choices[0].message.content
|
||||
return json.loads(content)
|
||||
except Exception:
|
||||
logger.warning("LLM result aggregation failed, using fallback")
|
||||
return _fallback_aggregate(results, query)
|
||||
@@ -0,0 +1,316 @@
|
||||
"""API routes for the Unified Search plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.jobs import enqueue_job
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
|
||||
from app.plugins.builtins.unified_search.query_understanding import (
|
||||
llm_aggregate_results,
|
||||
llm_analyze_query,
|
||||
)
|
||||
from app.plugins.builtins.unified_search.schemas import (
|
||||
ProviderResponse,
|
||||
ReindexRequest,
|
||||
SearchRequest,
|
||||
SearchResponse,
|
||||
SearchResult,
|
||||
SimilarRequest,
|
||||
SimilarResponse,
|
||||
SuggestRequest,
|
||||
SuggestResponse,
|
||||
)
|
||||
from app.plugins.builtins.unified_search.search_engine import (
|
||||
autocomplete,
|
||||
find_similar_all_types,
|
||||
hybrid_search,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
||||
|
||||
|
||||
# ─── Search ───
|
||||
|
||||
@router.post("", dependencies=[Depends(require_permission("search:read"))])
|
||||
async def search(
|
||||
req: SearchRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SearchResponse:
|
||||
"""Perform hybrid search with KI query understanding."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
|
||||
# KI query understanding
|
||||
query_analysis = await llm_analyze_query(req.query)
|
||||
|
||||
# Hybrid search
|
||||
results = await hybrid_search(
|
||||
db=db,
|
||||
query_analysis=query_analysis,
|
||||
tenant_id=tenant_id,
|
||||
entity_types=req.entity_types,
|
||||
limit=req.limit,
|
||||
)
|
||||
|
||||
# KI result aggregation
|
||||
aggregation = await llm_aggregate_results(results, req.query)
|
||||
|
||||
search_results = [
|
||||
SearchResult(
|
||||
entity_type=r.get("entity_type", ""),
|
||||
entity_id=r.get("entity_id", ""),
|
||||
title=r.get("title", ""),
|
||||
snippet=r.get("snippet", ""),
|
||||
score=r.get("score", 0.0),
|
||||
data=r.get("data", {}),
|
||||
)
|
||||
for r in results
|
||||
]
|
||||
|
||||
return SearchResponse(
|
||||
query=req.query,
|
||||
normalized_query=query_analysis.get("normalized_query", req.query),
|
||||
results=search_results,
|
||||
facets=aggregation.get("facets", {}),
|
||||
summary=aggregation.get("summary", f"{len(results)} Ergebnisse"),
|
||||
suggestions=aggregation.get("suggestions", []),
|
||||
)
|
||||
|
||||
|
||||
# ─── Suggest / Autocomplete ───
|
||||
|
||||
@router.get("/suggest", dependencies=[Depends(require_permission("search:read"))])
|
||||
async def suggest(
|
||||
q: str = Query(..., min_length=1, max_length=200),
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SuggestResponse:
|
||||
"""Autocomplete suggestions using FTS prefix search."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
suggestions = await autocomplete(db, q, tenant_id, limit)
|
||||
return SuggestResponse(suggestions=suggestions)
|
||||
|
||||
|
||||
# ─── Similar ───
|
||||
|
||||
@router.post("/similar", dependencies=[Depends(require_permission("search:read"))])
|
||||
async def find_similar(
|
||||
req: SimilarRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SimilarResponse:
|
||||
"""Find similar entities across all types based on embedding."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
entity_id = uuid.UUID(req.entity_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid entity_id")
|
||||
|
||||
similar = await find_similar_all_types(
|
||||
db=db,
|
||||
entity_type=req.entity_type,
|
||||
entity_id=entity_id,
|
||||
tenant_id=tenant_id,
|
||||
limit=req.limit,
|
||||
)
|
||||
|
||||
result_dict: dict[str, list[SearchResult]] = {}
|
||||
for etype, items in similar.items():
|
||||
result_dict[etype] = [
|
||||
SearchResult(
|
||||
entity_type=r.get("entity_type", etype),
|
||||
entity_id=r.get("entity_id", ""),
|
||||
title=r.get("title", ""),
|
||||
snippet=r.get("snippet", ""),
|
||||
score=r.get("score", 0.0),
|
||||
data=r.get("data", {}),
|
||||
)
|
||||
for r in items
|
||||
]
|
||||
|
||||
return SimilarResponse(similar=result_dict)
|
||||
|
||||
|
||||
# ─── Reindex ───
|
||||
|
||||
@router.post("/reindex", dependencies=[Depends(require_permission("search:admin"))])
|
||||
async def reindex(
|
||||
req: ReindexRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Trigger reindexing of specified entity types."""
|
||||
entity_types = req.entity_types
|
||||
if not entity_types:
|
||||
entity_types = get_search_registry().get_entity_types()
|
||||
|
||||
job_ids: list[str] = []
|
||||
for etype in entity_types:
|
||||
job_id = await enqueue_job("reindex", etype)
|
||||
if job_id:
|
||||
job_ids.append(job_id)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": f"Reindexing {len(entity_types)} entity types",
|
||||
"entity_types": entity_types,
|
||||
"job_ids": job_ids,
|
||||
}
|
||||
|
||||
|
||||
# ─── Providers ───
|
||||
|
||||
@router.get("/providers", dependencies=[Depends(require_permission("search:read"))])
|
||||
async def list_providers(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[ProviderResponse]:
|
||||
"""List all active search providers."""
|
||||
registry = get_search_registry()
|
||||
providers = registry.get_all()
|
||||
return [
|
||||
ProviderResponse(
|
||||
entity_type=p.entity_type,
|
||||
plugin_name="unified_search",
|
||||
is_active=True,
|
||||
)
|
||||
for p in providers
|
||||
]
|
||||
|
||||
|
||||
@router.post("/providers/{entity_type}/toggle", dependencies=[Depends(require_permission("search:admin"))])
|
||||
async def toggle_provider(
|
||||
entity_type: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Toggle a search provider on/off."""
|
||||
registry = get_search_registry()
|
||||
provider = registry.get(entity_type)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=404, detail=f"Provider not found: {entity_type}")
|
||||
|
||||
# Toggle in DB
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
result = await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT is_active FROM unified_search_providers
|
||||
WHERE tenant_id = :tid AND entity_type = :et
|
||||
"""
|
||||
),
|
||||
{"tid": tenant_id, "et": entity_type},
|
||||
)
|
||||
row = result.mappings().first()
|
||||
|
||||
if row:
|
||||
new_status = not row["is_active"]
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE unified_search_providers SET is_active = :active
|
||||
WHERE tenant_id = :tid AND entity_type = :et
|
||||
"""
|
||||
),
|
||||
{"active": new_status, "tid": tenant_id, "et": entity_type},
|
||||
)
|
||||
else:
|
||||
new_status = False
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO unified_search_providers (tenant_id, entity_type, plugin_name, is_active)
|
||||
VALUES (:tid, :et, 'unified_search', false)
|
||||
"""
|
||||
),
|
||||
{"tid": tenant_id, "et": entity_type},
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
if not new_status:
|
||||
registry.unregister(entity_type)
|
||||
else:
|
||||
# Re-register by clearing and re-running auto_register
|
||||
from app.plugins.builtins.unified_search.provider_registry import auto_register_providers
|
||||
await auto_register_providers(db)
|
||||
|
||||
return {
|
||||
"entity_type": entity_type,
|
||||
"is_active": new_status,
|
||||
}
|
||||
|
||||
|
||||
# ─── Stats ───
|
||||
|
||||
@router.get("/stats", dependencies=[Depends(require_permission("search:read"))])
|
||||
async def search_stats(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Get search index statistics."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
|
||||
stats: dict = {}
|
||||
tables = {
|
||||
"contacts": "embedding",
|
||||
"companies": "embedding",
|
||||
"mails": "embedding",
|
||||
"files": "embedding",
|
||||
"calendar_entries": "embedding",
|
||||
}
|
||||
|
||||
for table, col in tables.items():
|
||||
try:
|
||||
total_result = await db.execute(
|
||||
text(f"SELECT count(*) AS cnt FROM {table} WHERE tenant_id = :tid AND deleted_at IS NULL"),
|
||||
{"tid": tenant_id},
|
||||
)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
indexed_result = await db.execute(
|
||||
text(f"SELECT count(*) AS cnt FROM {table} WHERE tenant_id = :tid AND deleted_at IS NULL AND {col} IS NOT NULL"),
|
||||
{"tid": tenant_id},
|
||||
)
|
||||
indexed = indexed_result.scalar() or 0
|
||||
|
||||
stats[table] = {
|
||||
"total": total,
|
||||
"indexed": indexed,
|
||||
"pending": total - indexed,
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("Stats query failed for %s", table)
|
||||
stats[table] = {"total": 0, "indexed": 0, "pending": 0}
|
||||
|
||||
# Last index log entries
|
||||
try:
|
||||
log_result = await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT entity_type, action, status, created_at
|
||||
FROM unified_search_index_log
|
||||
WHERE tenant_id = :tid
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10
|
||||
"""
|
||||
),
|
||||
{"tid": tenant_id},
|
||||
)
|
||||
recent_logs = [dict(r) for r in log_result.mappings().all()]
|
||||
except Exception:
|
||||
recent_logs = []
|
||||
|
||||
stats["recent_logs"] = recent_logs
|
||||
return stats
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Pydantic schemas for the Unified Search plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ─── Search ───
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
query: str = Field(..., min_length=1, max_length=500)
|
||||
entity_types: list[str] | None = None
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
title: str
|
||||
snippet: str
|
||||
score: float
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
query: str
|
||||
normalized_query: str
|
||||
results: list[SearchResult]
|
||||
facets: dict[str, Any]
|
||||
summary: str
|
||||
suggestions: list[str]
|
||||
|
||||
|
||||
# ─── Similar ───
|
||||
|
||||
class SimilarRequest(BaseModel):
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
limit: int = Field(default=5, ge=1, le=50)
|
||||
|
||||
|
||||
class SimilarResponse(BaseModel):
|
||||
similar: dict[str, list[SearchResult]]
|
||||
|
||||
|
||||
# ─── Suggest ───
|
||||
|
||||
class SuggestRequest(BaseModel):
|
||||
q: str = Field(..., min_length=1, max_length=200)
|
||||
limit: int = Field(default=10, ge=1, le=50)
|
||||
|
||||
|
||||
class SuggestResponse(BaseModel):
|
||||
suggestions: list[str]
|
||||
|
||||
|
||||
# ─── Reindex ───
|
||||
|
||||
class ReindexRequest(BaseModel):
|
||||
entity_types: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ─── Provider ───
|
||||
|
||||
class ProviderResponse(BaseModel):
|
||||
entity_type: str
|
||||
plugin_name: str
|
||||
is_active: bool
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Hybrid search engine: FTS + pgvector with RRF fusion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.builtins.unified_search.embedding import generate_embedding
|
||||
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Entity type -> (table_name, tsv_column, embedding_column)
|
||||
SEARCHABLE_ENTITIES: dict[str, tuple[str, str, str]] = {
|
||||
"contact": ("contacts", "search_tsv", "embedding"),
|
||||
"company": ("companies", "search_tsv", "embedding"),
|
||||
"mail": ("mails", "body_tsv", "embedding"),
|
||||
"file": ("files", "content_tsv", "embedding"),
|
||||
"event": ("calendar_entries", "search_tsv", "embedding"),
|
||||
}
|
||||
|
||||
RRF_K = 60
|
||||
RRF_ALPHA = 0.5
|
||||
RRF_BETA = 0.5
|
||||
|
||||
|
||||
def rrf_fusion(
|
||||
fts_results: list[dict[str, Any]],
|
||||
vec_results: list[dict[str, Any]],
|
||||
entity_type: str,
|
||||
k: int = RRF_K,
|
||||
alpha: float = RRF_ALPHA,
|
||||
beta: float = RRF_BETA,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Reciprocal Rank Fusion of FTS and vector search results.
|
||||
|
||||
score = alpha * (1/(k+rank_fts)) + beta * (1/(k+rank_vec))
|
||||
"""
|
||||
fused: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for rank, item in enumerate(fts_results):
|
||||
eid = str(item.get("id", ""))
|
||||
if not eid:
|
||||
continue
|
||||
rrf_score = alpha * (1.0 / (k + rank + 1))
|
||||
if eid not in fused:
|
||||
fused[eid] = {**item, "_score": 0.0, "_entity_type": entity_type}
|
||||
fused[eid]["_score"] += rrf_score
|
||||
|
||||
for rank, item in enumerate(vec_results):
|
||||
eid = str(item.get("id", ""))
|
||||
if not eid:
|
||||
continue
|
||||
rrf_score = beta * (1.0 / (k + rank + 1))
|
||||
if eid not in fused:
|
||||
fused[eid] = {**item, "_score": 0.0, "_entity_type": entity_type}
|
||||
fused[eid]["_score"] += rrf_score
|
||||
|
||||
return sorted(fused.values(), key=lambda x: x.get("_score", 0.0), reverse=True)
|
||||
|
||||
|
||||
async def hybrid_search(
|
||||
db: AsyncSession,
|
||||
query_analysis: dict[str, Any],
|
||||
tenant_id: uuid.UUID,
|
||||
entity_types: list[str] | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Perform hybrid search across all entity types.
|
||||
|
||||
For each entity: FTS search + vector search + RRF fusion.
|
||||
Merge all results, sort by fused score, return top limit.
|
||||
"""
|
||||
registry = get_search_registry()
|
||||
all_entity_types = entity_types or registry.get_entity_types()
|
||||
|
||||
normalized_query = query_analysis.get("normalized_query", "")
|
||||
semantic_terms = query_analysis.get("semantic_terms", [])
|
||||
|
||||
# Build tsquery string
|
||||
tsquery_parts = [normalized_query] + semantic_terms
|
||||
tsquery = " & ".join(
|
||||
part.strip().replace(" ", " & ")
|
||||
for part in tsquery_parts
|
||||
if part and part.strip()
|
||||
)
|
||||
if not tsquery:
|
||||
tsquery = normalized_query
|
||||
|
||||
# Generate query embedding
|
||||
query_text = normalized_query
|
||||
if semantic_terms:
|
||||
query_text = f"{normalized_query} {' '.join(semantic_terms)}"
|
||||
query_embedding = await generate_embedding(query_text)
|
||||
|
||||
all_results: list[dict[str, Any]] = []
|
||||
fetch_limit = limit * 2
|
||||
|
||||
for entity_type in all_entity_types:
|
||||
provider = registry.get(entity_type)
|
||||
if provider is None:
|
||||
continue
|
||||
|
||||
fts_results: list[dict[str, Any]] = []
|
||||
vec_results: list[dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
fts_results = await provider.search_fts(db, tsquery, tenant_id, fetch_limit)
|
||||
except Exception:
|
||||
logger.exception("FTS search failed for %s", entity_type)
|
||||
|
||||
if query_embedding:
|
||||
try:
|
||||
vec_results = await provider.search_vector(db, query_embedding, tenant_id, fetch_limit)
|
||||
except Exception:
|
||||
logger.exception("Vector search failed for %s", entity_type)
|
||||
|
||||
fused = rrf_fusion(fts_results, vec_results, entity_type)
|
||||
|
||||
# Convert to search result format
|
||||
for item in fused:
|
||||
result = provider.to_search_result(item)
|
||||
result["score"] = item.get("_score", 0.0)
|
||||
all_results.append(result)
|
||||
|
||||
all_results.sort(key=lambda x: x.get("score", 0.0), reverse=True)
|
||||
return all_results[:limit]
|
||||
|
||||
|
||||
async def find_similar_all_types(
|
||||
db: AsyncSession,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int = 5,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Find similar entities across all types based on embedding.
|
||||
|
||||
Gets the embedding of the source entity, then searches all other tables.
|
||||
"""
|
||||
table_map = SEARCHABLE_ENTITIES
|
||||
source_info = table_map.get(entity_type)
|
||||
if not source_info:
|
||||
return {}
|
||||
|
||||
table_name, _, emb_col = source_info
|
||||
|
||||
# Get source embedding
|
||||
sql = text(
|
||||
f"SELECT {emb_col} AS embedding FROM {table_name} "
|
||||
f"WHERE id = :eid AND tenant_id = :tid"
|
||||
)
|
||||
result = await db.execute(sql, {"eid": entity_id, "tid": tenant_id})
|
||||
row = result.mappings().first()
|
||||
if not row or not row.get("embedding"):
|
||||
return {}
|
||||
|
||||
source_embedding_str = str(row["embedding"])
|
||||
|
||||
registry = get_search_registry()
|
||||
similar: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
for etype, (tbl, _, emb) in table_map.items():
|
||||
if etype == entity_type:
|
||||
continue
|
||||
provider = registry.get(etype)
|
||||
if provider is None:
|
||||
continue
|
||||
try:
|
||||
sql_sim = text(
|
||||
f"""
|
||||
SELECT *, 1 - ({emb} <=> cast(:emb_str AS vector)) AS score
|
||||
FROM {tbl}
|
||||
WHERE tenant_id = :tid
|
||||
AND deleted_at IS NULL
|
||||
AND {emb} IS NOT NULL
|
||||
ORDER BY {emb} <=> cast(:emb_str AS vector)
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
res = await db.execute(
|
||||
sql_sim,
|
||||
{"emb_str": source_embedding_str, "tid": tenant_id, "lim": limit},
|
||||
)
|
||||
rows = res.mappings().all()
|
||||
results = []
|
||||
for r in rows:
|
||||
sr = provider.to_search_result(dict(r))
|
||||
sr["score"] = float(r.get("score", 0.0))
|
||||
results.append(sr)
|
||||
similar[etype] = results
|
||||
except Exception:
|
||||
logger.exception("Similar search failed for %s", etype)
|
||||
similar[etype] = []
|
||||
|
||||
return similar
|
||||
|
||||
|
||||
async def autocomplete(
|
||||
db: AsyncSession,
|
||||
query: str,
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int = 10,
|
||||
) -> list[str]:
|
||||
"""Autocomplete using FTS prefix search.
|
||||
|
||||
Uses to_tsquery with :* suffix for prefix matching.
|
||||
"""
|
||||
query_clean = query.strip().replace(" ", " & ")
|
||||
if not query_clean:
|
||||
return []
|
||||
tsquery = f"{query_clean}:*"
|
||||
|
||||
suggestions: list[str] = []
|
||||
registry = get_search_registry()
|
||||
|
||||
for entity_type in registry.get_entity_types():
|
||||
provider = registry.get(entity_type)
|
||||
if provider is None:
|
||||
continue
|
||||
entity_info = SEARCHABLE_ENTITIES.get(entity_type)
|
||||
if not entity_info:
|
||||
continue
|
||||
table_name, tsv_col, _ = entity_info
|
||||
try:
|
||||
sql = text(
|
||||
f"""
|
||||
SELECT * FROM {table_name}
|
||||
WHERE tenant_id = :tid
|
||||
AND deleted_at IS NULL
|
||||
AND {tsv_col} @@ to_tsquery('pg_catalog.german', :q)
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
result = await db.execute(
|
||||
sql,
|
||||
{"q": tsquery, "tid": tenant_id, "lim": limit},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
for r in rows:
|
||||
sr = provider.to_search_result(dict(r))
|
||||
title = sr.get("title", "")
|
||||
if title and title not in suggestions:
|
||||
suggestions.append(title)
|
||||
except Exception:
|
||||
logger.debug("Autocomplete failed for %s", entity_type)
|
||||
|
||||
return suggestions[:limit]
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Text extraction from various file formats for DMS indexing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_CHARS = 50_000
|
||||
|
||||
|
||||
async def extract_text_from_file(file_path: str, mime_type: str) -> str:
|
||||
"""Extract text content from a file based on its MIME type.
|
||||
|
||||
Supports:
|
||||
- PDF (via PyMuPDF/fitz)
|
||||
- DOCX (via python-docx)
|
||||
- XLSX (via openpyxl)
|
||||
- PPTX (via python-pptx)
|
||||
- text/* (via aiofiles)
|
||||
- Images: returns empty (OCR not implemented)
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the file.
|
||||
mime_type: MIME type of the file.
|
||||
|
||||
Returns:
|
||||
Extracted text, truncated to MAX_CHARS. Empty string on error.
|
||||
"""
|
||||
try:
|
||||
if mime_type == "application/pdf":
|
||||
return await _extract_pdf(file_path)
|
||||
elif mime_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
return await _extract_docx(file_path)
|
||||
elif mime_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
|
||||
return await _extract_xlsx(file_path)
|
||||
elif mime_type == "application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
||||
return await _extract_pptx(file_path)
|
||||
elif mime_type.startswith("text/"):
|
||||
return await _extract_text(file_path)
|
||||
elif mime_type.startswith("image/"):
|
||||
return ""
|
||||
else:
|
||||
logger.debug("Unsupported mime_type=%s for text extraction", mime_type)
|
||||
return ""
|
||||
except Exception:
|
||||
logger.warning("Failed to extract text from %s (mime=%s)", file_path, mime_type)
|
||||
return ""
|
||||
|
||||
|
||||
def _truncate(text: str) -> str:
|
||||
"""Truncate text to MAX_CHARS with indicator."""
|
||||
if len(text) > MAX_CHARS:
|
||||
return text[:MAX_CHARS] + "... [truncated]"
|
||||
return text
|
||||
|
||||
|
||||
async def _extract_pdf(file_path: str) -> str:
|
||||
"""Extract text from PDF using PyMuPDF (fitz)."""
|
||||
import fitz # PyMuPDF
|
||||
|
||||
doc = fitz.open(file_path)
|
||||
parts: list[str] = []
|
||||
for page in doc:
|
||||
parts.append(page.get_text())
|
||||
doc.close()
|
||||
return _truncate("\n".join(parts))
|
||||
|
||||
|
||||
async def _extract_docx(file_path: str) -> str:
|
||||
"""Extract text from DOCX using python-docx."""
|
||||
from docx import Document
|
||||
|
||||
doc = Document(file_path)
|
||||
parts: list[str] = []
|
||||
for para in doc.paragraphs:
|
||||
if para.text.strip():
|
||||
parts.append(para.text)
|
||||
# Also extract table text
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
if cell.text.strip():
|
||||
parts.append(cell.text)
|
||||
return _truncate("\n".join(parts))
|
||||
|
||||
|
||||
async def _extract_xlsx(file_path: str) -> str:
|
||||
"""Extract text from XLSX using openpyxl."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(file_path, read_only=True, data_only=True)
|
||||
parts: list[str] = []
|
||||
for ws in wb.worksheets:
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
row_text = " ".join(str(c) for c in row if c is not None)
|
||||
if row_text.strip():
|
||||
parts.append(row_text)
|
||||
wb.close()
|
||||
return _truncate("\n".join(parts))
|
||||
|
||||
|
||||
async def _extract_pptx(file_path: str) -> str:
|
||||
"""Extract text from PPTX using python-pptx."""
|
||||
from pptx import Presentation
|
||||
|
||||
prs = Presentation(file_path)
|
||||
parts: list[str] = []
|
||||
for slide in prs.slides:
|
||||
for shape in slide.shapes:
|
||||
if shape.has_text_frame:
|
||||
for para in shape.text_frame.paragraphs:
|
||||
text = para.text.strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
return _truncate("\n".join(parts))
|
||||
|
||||
|
||||
async def _extract_text(file_path: str) -> str:
|
||||
"""Read plain text file using aiofiles."""
|
||||
import aiofiles
|
||||
|
||||
async with aiofiles.open(file_path, mode="r", encoding="utf-8", errors="replace") as f:
|
||||
content = await f.read()
|
||||
return _truncate(content)
|
||||
Reference in New Issue
Block a user