4a43745b50
- query_understanding.py: get API key/base_url/provider_type from ai_providers DB - embedding.py: get API key from DB, pass db+tenant_id through call chain - routes.py: pass db+tenant_id to llm_analyze_query and llm_aggregate_results - search_engine.py: pass db+tenant_id to generate_embedding - unified_search/jobs.py: pass db+tenant_id to generate_embedding - Fix all default model names: ollama/deepseek-v4 -> ollama/deepseek-v4-flash - Ollama Cloud has no embedding endpoint; embedding calls fail gracefully
317 lines
9.6 KiB
Python
317 lines
9.6 KiB
Python
"""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, db=db, tenant_id=tenant_id)
|
|
|
|
# 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, db=db, tenant_id=tenant_id)
|
|
|
|
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
|