Files
leocrm/app/plugins/builtins/unified_search/routes.py
T
Agent Zero 03dd477899
Check Cross-Plugin Imports / check (push) Has been cancelled
feat(N4): Restliche Module — Tasks/Kommunikation/Wiki/Reports/Agents/Tags/Search + Navigation + Dashboard-Schnittstelle (#368)
- Scope-Deklarationen: tasks only_mine, kommunikation conversation_ids, wiki category_ids (NEUE contracts.py), report_generator template_ids, automation agent_ids (module_key agents), tags tag_ids, unified_search entity_types dynamisch aus Provider-Registry
- Core-Beiträge: navigation default_route (Startseite) + dashboard widget_app_ids (Widget-TYP-Angebot, Layout bleibt Phase M)
- Backend-Filter (additive UND): /tasks (only_mine), /comm/conversations, /wiki/articles+/categories (Subtree), /reports/print-templates, /agents, /tags, /search GET+POST (entity_types-Schnitt), /miniapps?host=dashboard
- apply_entity_type_scope-Helper (requested ∧ scope)
- Frontend: WorkspaceSwitcher default_route-Navigation, Sidebar workspace-menu_order-Sortierung, workspaceStore moduleMenuOrder()
- Tests: 18/18 Deklarationen + 11/11 Filter (TDD), Frontend 2/2 + Store 18/18, tsc clean, Build OK
- Regression 64 passed (4 Kombi-Failures = Suite-Isolation, solo-bewiesen); Checker 0; Ruff = Vorbestand (Stash-bewiesen)
2026-09-01 23:23:15 +02:00

605 lines
20 KiB
Python

"""API routes for the Unified Search plugin."""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
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.core.permissions import filter_fields_by_permission, resolve_permissions
from app.deps import get_current_user, require_permission, require_workspace_scope
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 (
FacetsResponse,
ProviderResponse,
ReindexRequest,
SearchRequest,
SearchResponse,
SearchResult,
SimilarRequest,
SimilarResponse,
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.get("", dependencies=[Depends(require_permission("search:read"))])
async def search_get(
q: str = Query(..., min_length=1, max_length=500, description="Search query"),
entity_types: str | None = Query(None, description="Comma-separated entity types to search"),
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
date_from: str | None = Query(None, description="ISO date (YYYY-MM-DD) — filter by created_at/updated_at >= date_from"),
date_to: str | None = Query(None, description="ISO date (YYYY-MM-DD) — filter by created_at/updated_at <= date_to"),
tags: str | None = Query(None, description="Comma-separated tags to filter results"),
sort: str = Query(default="relevance", description="Sort order: relevance, date, name"),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
workspace_scope: dict | None = Depends(require_workspace_scope("search")),
) -> SearchResponse:
"""Perform hybrid search via GET (same as POST but with query params).
Phase N4: an active workspace scope intersects the requested entity
types with the configured search areas (pure AND — never a grant).
"""
types_list = entity_types.split(",") if entity_types else None
tags_list = tags.split(",") if tags else None
req = SearchRequest(
query=q,
entity_types=types_list,
limit=limit,
offset=offset,
date_from=date_from,
date_to=date_to,
tags=tags_list,
sort=sort,
)
if workspace_scope:
from app.services.workspace_scope_service import apply_entity_type_scope
req.entity_types = apply_entity_type_scope(
req.entity_types, workspace_scope.get("entity_types")
)
return await _do_search(req, current_user, db)
def _parse_date(value: str | None) -> datetime | None:
"""Parse an ISO date string (YYYY-MM-DD) into a timezone-aware datetime."""
if not value:
return None
try:
dt = datetime.fromisoformat(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt
except ValueError:
return None
def _apply_filters_and_sort(
results: list[dict],
req: SearchRequest,
) -> list[dict]:
"""Apply post-search date/tags filters and sort order to results."""
date_from = _parse_date(req.date_from)
date_to = _parse_date(req.date_to)
filtered: list[dict] = []
for r in results:
# Date range filter on created_at/updated_at
if date_from or date_to:
created = r.get("_created_at")
updated = r.get("_updated_at")
ts = updated or created
if ts is None:
continue
if isinstance(ts, str):
try:
ts = datetime.fromisoformat(ts.replace("Z", "+00:00"))
except ValueError:
continue
if ts.tzinfo is None:
ts = ts.replace(tzinfo=UTC)
if date_from and ts < date_from:
continue
if date_to and ts > date_to:
continue
# Tags filter (comma-separated on entity)
if req.tags:
entity_tags = r.get("_tags") or ""
tag_set = {t.strip().lower() for t in entity_tags.split(",") if t.strip()}
if not any(t.lower() in tag_set for t in req.tags):
continue
filtered.append(r)
# Sort order
sort = (req.sort or "relevance").lower()
if sort == "date":
filtered.sort(
key=lambda x: (x.get("_updated_at") or x.get("_created_at") or ""),
reverse=True,
)
elif sort == "name":
filtered.sort(key=lambda x: (x.get("title") or "").lower())
# 'relevance' keeps the existing score order
return filtered
async def _do_search(
req: SearchRequest,
current_user: dict,
db: AsyncSession,
) -> SearchResponse:
"""Shared search logic used by both GET and POST endpoints."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("is_system_admin", False)
# KI query understanding (skip if use_ai=False for performance)
use_ai = getattr(req, "use_ai", True)
if use_ai:
query_analysis = await llm_analyze_query(req.query, db=db, tenant_id=tenant_id)
else:
from app.plugins.builtins.unified_search.query_understanding import _fallback_query_analysis
query_analysis = _fallback_query_analysis(req.query)
# Hybrid search with visibility filtering
results = await hybrid_search(
db=db,
query_analysis=query_analysis,
tenant_id=tenant_id,
entity_types=req.entity_types,
limit=req.limit,
user_id=user_id,
is_system_admin=is_system_admin,
)
# Apply post-search filters (date range, tags) and sort
results = _apply_filters_and_sort(results, req)
# Resolve user permissions for field-level RBAC
resolved_perms = await resolve_permissions(db, user_id, tenant_id)
# Map entity_type to module name for field-level permissions
_entity_to_module = {
"contact": "contacts",
"mail": "mail",
"file": "dms",
"event": "calendar",
}
# KI result aggregation (skip if use_ai=False for performance)
if use_ai:
aggregation = await llm_aggregate_results(results, req.query, db=db, tenant_id=tenant_id)
else:
from app.plugins.builtins.unified_search.query_understanding import _fallback_aggregate
aggregation = _fallback_aggregate(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=filter_fields_by_permission(
r.get("data", {}),
resolved_perms,
_entity_to_module.get(r.get("entity_type", ""), r.get("entity_type", "")),
),
)
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", []),
)
@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),
workspace_scope: dict | None = Depends(require_workspace_scope("search")),
) -> SearchResponse:
"""Perform hybrid search with KI query understanding.
Phase N4: an active workspace scope intersects the requested entity
types with the configured search areas (pure AND — never a grant).
"""
if workspace_scope:
from app.services.workspace_scope_service import apply_entity_type_scope
req.entity_types = apply_entity_type_scope(
req.entity_types, workspace_scope.get("entity_types")
)
return await _do_search(req, current_user, db)
# ─── Facets ───
@router.get("/facets", dependencies=[Depends(require_permission("search:read"))])
async def search_facets(
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> FacetsResponse:
"""Return available facets for search filtering (entity types, tags, date ranges)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
# Entity types from the search registry
registry = get_search_registry()
entity_types = registry.get_entity_types()
# Tags: distinct tags across searchable entities (comma-separated on entities)
tags: set[str] = set()
tag_tables = {
"contacts": "tags",
"companies": "tags",
}
for table, col in tag_tables.items():
try:
result = await db.execute(
text(
f"SELECT DISTINCT unnest(string_to_array({col}, ',')) AS tag "
f"FROM {table} WHERE tenant_id = :tid AND deleted_at IS NULL AND {col} IS NOT NULL"
),
{"tid": tenant_id},
)
for row in result.mappings().all():
tag = (row.get("tag") or "").strip()
if tag:
tags.add(tag)
except Exception:
logger.debug("Facet tag query failed for %s", table)
# Date ranges: min/max created_at across searchable entities
date_ranges: dict[str, Any] = {}
date_tables = ["contacts", "mails", "files", "calendar_entries"]
for table in date_tables:
try:
result = await db.execute(
text(
f"SELECT min(created_at) AS min_dt, max(created_at) AS max_dt "
f"FROM {table} WHERE tenant_id = :tid AND deleted_at IS NULL"
),
{"tid": tenant_id},
)
row = result.mappings().first()
if row:
date_ranges[table] = {
"min": str(row.get("min_dt")) if row.get("min_dt") else None,
"max": str(row.get("max_dt")) if row.get("max_dt") else None,
}
except Exception:
logger.debug("Facet date query failed for %s", table)
return FacetsResponse(
entity_types=entity_types,
tags=sorted(tags),
date_ranges=date_ranges,
)
# ─── 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") from None
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)
# Optionally re-index file chunks
if req.include_chunks and "file" in entity_types:
chunk_job_id = await enqueue_job("reindex_all")
if chunk_job_id:
job_ids.append(chunk_job_id)
return {
"status": "ok",
"message": f"Reindexing {len(entity_types)} entity types",
"entity_types": entity_types,
"include_chunks": req.include_chunks,
"job_ids": job_ids,
}
# ─── Rebuild / Purge ───
@router.post("/rebuild/{entity_type}/{entity_id}", dependencies=[Depends(require_permission("search:admin"))])
async def rebuild_entity_index(
entity_type: str,
entity_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Rebuild the search index for a single entity (admin only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
eid = uuid.UUID(entity_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid entity_id") from None
from app.plugins.builtins.unified_search.lifecycle import rebuild_index
success = await rebuild_index(db, entity_type, eid, tenant_id)
return {
"status": "ok" if success else "failed",
"entity_type": entity_type,
"entity_id": entity_id,
"message": "Index rebuilt" if success else "Rebuild failed — check logs",
}
@router.post("/purge/{entity_type}/{entity_id}", dependencies=[Depends(require_permission("search:admin"))])
async def purge_entity_index(
entity_type: str,
entity_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Purge an entity from the search index (admin only, GDPR)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
eid = uuid.UUID(entity_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid entity_id") from None
from app.plugins.builtins.unified_search.lifecycle import remove_chunks, remove_from_index
await remove_from_index(db, entity_type, eid, tenant_id)
if entity_type == "file":
await remove_chunks(db, eid, tenant_id)
return {
"status": "ok",
"entity_type": entity_type,
"entity_id": entity_id,
"message": "Purged from search index",
}
# ─── 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,
supports_fts=getattr(p, "supports_fts", True),
supports_vector=getattr(p, "supports_vector", True),
supports_rag=getattr(p, "supports_rag", False),
supports_graph=getattr(p, "supports_graph", False),
)
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",
"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