Files
leocrm/app/plugins/builtins/unified_search/routes.py
T

317 lines
9.5 KiB
Python
Raw Normal View History

"""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