abbe7a18fc
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner - P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var - P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup - P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns - P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs) - P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import - P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default - P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed - P2: 28 frontend TODOs (hardcoded constants, deprecated notification API) - P3: dead code, duplicates, deprecated imports, private attr, __import__ inline - P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n) - ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix) - F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String) - Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
139 lines
4.1 KiB
Python
139 lines
4.1 KiB
Python
"""Derived-data lifecycle management for the Unified Search index.
|
|
|
|
Provides functions to remove, rebuild, and purge search index data
|
|
(embeddings, FTS vectors, document chunks) when entities are deleted,
|
|
restored, or corrected.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
from typing import TYPE_CHECKING
|
|
|
|
from sqlalchemy import text
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Entity type -> (table_name, tsv_column, embedding_column)
|
|
_ENTITY_MAP: dict[str, tuple[str, str, str]] = {
|
|
"contact": ("contacts", "search_tsv", "embedding"),
|
|
"mail": ("mails", "body_tsv", "embedding"),
|
|
"file": ("files", "content_tsv", "embedding"),
|
|
"event": ("calendar_entries", "search_tsv", "embedding"),
|
|
}
|
|
|
|
|
|
def _resolve_entity(entity_type: str) -> tuple[str, str, str] | None:
|
|
"""Return (table, tsv_column, embedding_column) for an entity type."""
|
|
return _ENTITY_MAP.get(entity_type)
|
|
|
|
|
|
async def remove_from_index(
|
|
db: AsyncSession,
|
|
entity_type: str,
|
|
entity_id: uuid.UUID,
|
|
tenant_id: uuid.UUID,
|
|
) -> None:
|
|
"""Remove an entity from the search index (embedding=NULL, TSV=NULL).
|
|
|
|
Does **not** delete document_chunks — use :func:`remove_chunks` for that.
|
|
"""
|
|
mapping = _resolve_entity(entity_type)
|
|
if mapping is None:
|
|
logger.warning("remove_from_index: unknown entity_type=%s", entity_type)
|
|
return
|
|
|
|
table, tsv_col, emb_col = mapping
|
|
await db.execute(
|
|
text(
|
|
f"UPDATE {table} "
|
|
f"SET {emb_col} = NULL, {tsv_col} = NULL, indexed_at = NULL "
|
|
f"WHERE id = :eid AND tenant_id = :tid"
|
|
),
|
|
{"eid": entity_id, "tid": tenant_id},
|
|
)
|
|
await db.commit()
|
|
logger.info("Removed %s/%s from search index", entity_type, entity_id)
|
|
|
|
|
|
async def remove_chunks(
|
|
db: AsyncSession,
|
|
file_id: uuid.UUID,
|
|
tenant_id: uuid.UUID,
|
|
) -> None:
|
|
"""Remove all document_chunks for a file."""
|
|
await db.execute(
|
|
text(
|
|
"DELETE FROM document_chunks "
|
|
"WHERE file_id = :fid AND tenant_id = :tid"
|
|
),
|
|
{"fid": file_id, "tid": tenant_id},
|
|
)
|
|
await db.commit()
|
|
logger.info("Removed chunks for file %s", file_id)
|
|
|
|
|
|
async def rebuild_index(
|
|
db: AsyncSession,
|
|
entity_type: str,
|
|
entity_id: uuid.UUID,
|
|
tenant_id: uuid.UUID,
|
|
) -> bool:
|
|
"""Rebuild the search index for a single entity from the authoritative source.
|
|
|
|
Re-generates the embedding and refreshes the TSV vector.
|
|
Returns True on success, False on failure.
|
|
"""
|
|
from app.plugins.builtins.unified_search.embedding import index_entity
|
|
|
|
# Clear existing index data first
|
|
await remove_from_index(db, entity_type, entity_id, tenant_id)
|
|
|
|
# Re-generate embedding (index_entity also updates indexed_at)
|
|
try:
|
|
success = await index_entity(entity_type, entity_id, tenant_id, db)
|
|
if success and entity_type == "file":
|
|
# Re-index chunks for files
|
|
from app.plugins.builtins.unified_search.jobs import index_file_chunks
|
|
await index_file_chunks({"_lifecycle": True}, str(entity_id))
|
|
return success
|
|
except Exception:
|
|
logger.exception("rebuild_index failed for %s/%s", entity_type, entity_id)
|
|
return False
|
|
|
|
|
|
async def handle_entity_delete(
|
|
db: AsyncSession,
|
|
entity_type: str,
|
|
entity_id: uuid.UUID,
|
|
tenant_id: uuid.UUID,
|
|
) -> None:
|
|
"""Handle entity deletion: remove from index + remove chunks if applicable."""
|
|
await remove_from_index(db, entity_type, entity_id, tenant_id)
|
|
if entity_type == "file":
|
|
await remove_chunks(db, entity_id, tenant_id)
|
|
|
|
|
|
async def handle_entity_restore(
|
|
db: AsyncSession,
|
|
entity_type: str,
|
|
entity_id: uuid.UUID,
|
|
tenant_id: uuid.UUID,
|
|
) -> None:
|
|
"""Handle entity restore from trash: rebuild search index."""
|
|
await rebuild_index(db, entity_type, entity_id, tenant_id)
|
|
|
|
|
|
async def handle_entity_correction(
|
|
db: AsyncSession,
|
|
entity_type: str,
|
|
entity_id: uuid.UUID,
|
|
tenant_id: uuid.UUID,
|
|
) -> None:
|
|
"""Handle entity data correction: rebuild search index."""
|
|
await rebuild_index(db, entity_type, entity_id, tenant_id)
|