From cda232101fad496723259efaf1043c0387ed15a6 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:50:20 +0530 Subject: [PATCH] feat(workspace): remove AI file sorting feature and related components --- README.md | 2 - .../versions/172_remove_ai_file_sort.py | 31 ++ surfsense_backend/app/db.py | 4 - .../events/document_entered_folder.py | 2 +- .../indexing_pipeline_service.py | 25 -- .../app/routes/workspaces_routes.py | 38 -- surfsense_backend/app/schemas/workspace.py | 2 - .../app/services/ai_file_sort_service.py | 329 ------------------ .../app/tasks/celery_tasks/document_tasks.py | 106 ------ .../tests/unit/observability/test_helpers.py | 1 - .../services/test_ai_file_sort_service.py | 275 --------------- .../unit/services/test_ai_sort_task_dedupe.py | 43 --- .../unit/services/test_folder_hierarchy.py | 4 +- .../components/documents/DocumentsFilters.tsx | 51 +-- .../components/documents/FolderTreeView.tsx | 56 +-- .../layout/ui/sidebar/DocumentsSidebar.tsx | 63 ---- .../contracts/types/search-space.types.ts | 2 - .../lib/apis/search-spaces-api.service.ts | 11 - 18 files changed, 40 insertions(+), 1005 deletions(-) create mode 100644 surfsense_backend/alembic/versions/172_remove_ai_file_sort.py delete mode 100644 surfsense_backend/app/services/ai_file_sort_service.py delete mode 100644 surfsense_backend/tests/unit/services/test_ai_file_sort_service.py delete mode 100644 surfsense_backend/tests/unit/services/test_ai_sort_task_dedupe.py diff --git a/README.md b/README.md index a75122892..d116ef64d 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,6 @@ NotebookLM is one of the best and most useful AI platforms out there, but once y - **No Vendor Lock-in** - Configure any LLM, image, TTS, and STT models to use. - **25+ External Data Sources** - Add your sources from Google Drive, OneDrive, Dropbox, Notion, and many other external services. - **Real-Time Multiplayer Support** - Work easily with your team members in a shared notebook. -- **AI File Sorting** - Automatically organize your documents into a smart folder hierarchy using AI-powered categorization by source, date, and topic. - **AI Automations & Agents** - Run AI agents on a schedule or trigger them the moment a document lands in a folder, then write results back to Notion, Slack, Linear, and Drive. Build no-code automations just by describing them in chat. - **Desktop App** - Get AI assistance in any application with Quick Assist, General Assist, Screenshot Assist, and local folder sync. @@ -271,7 +270,6 @@ All features operate against your chosen search space, so your answers are alway | **Video Generation** | Cinematic Video Overviews via Veo 3 (Ultra only) | Available (NotebookLM is better here, actively improving) | | **Presentation Generation** | Better looking slides but not editable | Create editable, slide-based presentations | | **Podcast Generation** | Audio Overviews with customizable hosts and languages | Available with multiple TTS providers (NotebookLM is better here, actively improving) | -| **AI File Sorting** | No | LLM-powered auto-categorization into source, date, category, and subcategory folders | | **AI Automations & Agents** | No | Scheduled AI workflows, event triggers on new documents, and chat-built no-code automations with connector write-back to Notion, Slack, Linear & Jira | | **Desktop App** | No | Native app with General Assist, Quick Assist, Screenshot Assist, and local folder sync | | **Browser Extension** | No | Cross-browser extension to save any webpage, including auth-protected pages | diff --git a/surfsense_backend/alembic/versions/172_remove_ai_file_sort.py b/surfsense_backend/alembic/versions/172_remove_ai_file_sort.py new file mode 100644 index 000000000..6db2d5c8a --- /dev/null +++ b/surfsense_backend/alembic/versions/172_remove_ai_file_sort.py @@ -0,0 +1,31 @@ +"""Remove AI file sort flag. + +Revision ID: 172 +Revises: 171 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +revision: str = "172" +down_revision: str | None = "171" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_column("workspaces", "ai_file_sort_enabled") + + +def downgrade() -> None: + op.add_column( + "workspaces", + sa.Column( + "ai_file_sort_enabled", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index dd13b1274..3cfdb5f24 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1732,10 +1732,6 @@ class Workspace(BaseModel, TimestampMixin): Integer, nullable=True, default=0, server_default="0" ) # For vision/screenshot analysis, defaults to Auto mode - ai_file_sort_enabled = Column( - Boolean, nullable=False, default=False, server_default="false" - ) - user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) diff --git a/surfsense_backend/app/event_bus/events/document_entered_folder.py b/surfsense_backend/app/event_bus/events/document_entered_folder.py index 66560e9e5..d6510eb84 100644 --- a/surfsense_backend/app/event_bus/events/document_entered_folder.py +++ b/surfsense_backend/app/event_bus/events/document_entered_folder.py @@ -1,6 +1,6 @@ """``document.entered_folder``: a document became a member of a folder. -Fires once per arrival, however the document got there (upload, AI sort, move). +Fires once per arrival, however the document got there (upload or move). The payload carries the fields a user can filter a trigger on. """ diff --git a/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py b/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py index a9f2dd757..539072cf0 100644 --- a/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py +++ b/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py @@ -427,8 +427,6 @@ class IndexingPipelineService: log_index_success(ctx, chunk_count=chunk_count) outcome_status = "success" - await self._enqueue_ai_sort_if_enabled(document) - except RETRYABLE_LLM_ERRORS as e: ot.record_error(persist_span, e) log_retryable_llm_error(ctx, e) @@ -564,29 +562,6 @@ class IndexingPipelineService: ) return len(new_texts) - async def _enqueue_ai_sort_if_enabled(self, document: Document) -> None: - """Fire-and-forget: enqueue incremental AI sort if the workspace has it enabled.""" - try: - from app.db import Workspace - - result = await self.session.execute( - select(Workspace.ai_file_sort_enabled).where( - Workspace.id == document.workspace_id - ) - ) - enabled = result.scalar() - if not enabled: - return - - from app.tasks.celery_tasks.document_tasks import ai_sort_document_task - - user_id = str(document.created_by_id) if document.created_by_id else "" - ai_sort_document_task.delay(document.workspace_id, user_id, document.id) - except Exception: - logging.getLogger(__name__).warning( - "Failed to enqueue AI sort for document %s", document.id, exc_info=True - ) - async def index_batch_parallel( self, connector_docs: list[ConnectorDocument], diff --git a/surfsense_backend/app/routes/workspaces_routes.py b/surfsense_backend/app/routes/workspaces_routes.py index 55eaa8ea9..4f36ef62b 100644 --- a/surfsense_backend/app/routes/workspaces_routes.py +++ b/surfsense_backend/app/routes/workspaces_routes.py @@ -189,7 +189,6 @@ async def read_workspaces( citations_enabled=space.citations_enabled, api_access_enabled=space.api_access_enabled, qna_custom_instructions=space.qna_custom_instructions, - ai_file_sort_enabled=space.ai_file_sort_enabled, member_count=member_count, is_owner=is_owner, ) @@ -326,43 +325,6 @@ async def update_workspace_api_access( ) from e -@router.post("/workspaces/{workspace_id}/ai-sort") -async def trigger_ai_sort( - workspace_id: int, - session: AsyncSession = Depends(get_async_session), - auth: AuthContext = Depends(get_auth_context), -): - user = auth.user - """Trigger a full AI file sort for all documents in the workspace.""" - try: - await check_permission( - session, - auth, - workspace_id, - Permission.SETTINGS_UPDATE.value, - "You don't have permission to trigger AI sort on this workspace", - ) - - result = await session.execute( - select(Workspace).filter(Workspace.id == workspace_id) - ) - db_workspace = result.scalars().first() - if not db_workspace: - raise HTTPException(status_code=404, detail="Workspace not found") - - from app.tasks.celery_tasks.document_tasks import ai_sort_workspace_task - - ai_sort_workspace_task.delay(workspace_id, str(user.id)) - return {"message": "AI sort started"} - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to trigger AI sort: {e!s}", exc_info=True) - raise HTTPException( - status_code=500, detail=f"Failed to trigger AI sort: {e!s}" - ) from e - - @router.delete("/workspaces/{workspace_id}", response_model=dict) async def delete_workspace( workspace_id: int, diff --git a/surfsense_backend/app/schemas/workspace.py b/surfsense_backend/app/schemas/workspace.py index 15c754e81..57b7e0a52 100644 --- a/surfsense_backend/app/schemas/workspace.py +++ b/surfsense_backend/app/schemas/workspace.py @@ -21,7 +21,6 @@ class WorkspaceUpdate(BaseModel): description: str | None = None citations_enabled: bool | None = None qna_custom_instructions: str | None = None - ai_file_sort_enabled: bool | None = None class WorkspaceApiAccessUpdate(BaseModel): @@ -36,7 +35,6 @@ class WorkspaceRead(WorkspaceBase, IDModel, TimestampModel): api_access_enabled: bool = False qna_custom_instructions: str | None = None shared_memory_md: str | None = None - ai_file_sort_enabled: bool = False model_config = ConfigDict(from_attributes=True) diff --git a/surfsense_backend/app/services/ai_file_sort_service.py b/surfsense_backend/app/services/ai_file_sort_service.py deleted file mode 100644 index cc4727608..000000000 --- a/surfsense_backend/app/services/ai_file_sort_service.py +++ /dev/null @@ -1,329 +0,0 @@ -"""AI File Sort Service: builds connector-type/date/category/subcategory folder paths.""" - -from __future__ import annotations - -import json -import logging -import re -from datetime import UTC, datetime - -from langchain_core.messages import HumanMessage -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select -from sqlalchemy.orm import selectinload - -from app.db import ( - Chunk, - Document, - DocumentType, - SearchSourceConnector, - SearchSourceConnectorType, -) -from app.services.folder_service import ensure_folder_hierarchy_with_depth_validation - -logger = logging.getLogger(__name__) - -_DOCTYPE_TO_CONNECTOR_LABEL: dict[str, str] = { - DocumentType.EXTENSION: "Browser Extension", - DocumentType.CRAWLED_URL: "Web Crawl", - DocumentType.FILE: "File Upload", - DocumentType.SLACK_CONNECTOR: "Slack", - DocumentType.TEAMS_CONNECTOR: "Teams", - DocumentType.ONEDRIVE_FILE: "OneDrive", - DocumentType.NOTION_CONNECTOR: "Notion", - DocumentType.YOUTUBE_VIDEO: "YouTube", - DocumentType.GITHUB_CONNECTOR: "GitHub", - DocumentType.LINEAR_CONNECTOR: "Linear", - DocumentType.DISCORD_CONNECTOR: "Discord", - DocumentType.JIRA_CONNECTOR: "Jira", - DocumentType.CONFLUENCE_CONNECTOR: "Confluence", - DocumentType.CLICKUP_CONNECTOR: "ClickUp", - DocumentType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar", - DocumentType.GOOGLE_GMAIL_CONNECTOR: "Gmail", - DocumentType.GOOGLE_DRIVE_FILE: "Google Drive", - DocumentType.AIRTABLE_CONNECTOR: "Airtable", - DocumentType.LUMA_CONNECTOR: "Luma", - DocumentType.ELASTICSEARCH_CONNECTOR: "Elasticsearch", - DocumentType.BOOKSTACK_CONNECTOR: "BookStack", - DocumentType.CIRCLEBACK: "Circleback", - DocumentType.OBSIDIAN_CONNECTOR: "Obsidian", - DocumentType.NOTE: "Notes", - DocumentType.DROPBOX_FILE: "Dropbox", - DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Google Drive (Composio)", - DocumentType.COMPOSIO_GMAIL_CONNECTOR: "Gmail (Composio)", - DocumentType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Google Calendar (Composio)", - DocumentType.LOCAL_FOLDER_FILE: "Local Folder", -} - -_CONNECTOR_TYPE_LABEL: dict[str, str] = { - SearchSourceConnectorType.SERPER_API: "Serper Search", - SearchSourceConnectorType.TAVILY_API: "Tavily Search", - SearchSourceConnectorType.SEARXNG_API: "SearXNG Search", - SearchSourceConnectorType.LINKUP_API: "Linkup Search", - SearchSourceConnectorType.BAIDU_SEARCH_API: "Baidu Search", - SearchSourceConnectorType.SLACK_CONNECTOR: "Slack", - SearchSourceConnectorType.TEAMS_CONNECTOR: "Teams", - SearchSourceConnectorType.ONEDRIVE_CONNECTOR: "OneDrive", - SearchSourceConnectorType.NOTION_CONNECTOR: "Notion", - SearchSourceConnectorType.GITHUB_CONNECTOR: "GitHub", - SearchSourceConnectorType.LINEAR_CONNECTOR: "Linear", - SearchSourceConnectorType.DISCORD_CONNECTOR: "Discord", - SearchSourceConnectorType.JIRA_CONNECTOR: "Jira", - SearchSourceConnectorType.CONFLUENCE_CONNECTOR: "Confluence", - SearchSourceConnectorType.CLICKUP_CONNECTOR: "ClickUp", - SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar", - SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR: "Gmail", - SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR: "Google Drive", - SearchSourceConnectorType.AIRTABLE_CONNECTOR: "Airtable", - SearchSourceConnectorType.LUMA_CONNECTOR: "Luma", - SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR: "Elasticsearch", - SearchSourceConnectorType.WEBCRAWLER_CONNECTOR: "Web Crawl", - SearchSourceConnectorType.BOOKSTACK_CONNECTOR: "BookStack", - SearchSourceConnectorType.CIRCLEBACK_CONNECTOR: "Circleback", - SearchSourceConnectorType.OBSIDIAN_CONNECTOR: "Obsidian", - SearchSourceConnectorType.MCP_CONNECTOR: "MCP", - SearchSourceConnectorType.DROPBOX_CONNECTOR: "Dropbox", - SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Google Drive (Composio)", - SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR: "Gmail (Composio)", - SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Google Calendar (Composio)", -} - -_MAX_CONTENT_CHARS = 4000 -_MAX_CHUNKS_FOR_CONTEXT = 5 - -_CATEGORY_PROMPT = ( - "Based on the document information below, classify it into a broad category " - "and a more specific subcategory.\n\n" - "Rules:\n" - "- category: 1-2 word broad theme (e.g. Science, Finance, Engineering, Communication, Media)\n" - "- subcategory: 1-2 word specific topic within the category " - "(e.g. Physics, Tax Reports, Backend, Team Updates)\n" - "- Use nouns only. Do not include generic terms like 'General' or 'Miscellaneous'.\n\n" - "Title: {title}\n\n" - "Content: {summary}\n\n" - 'Respond with ONLY a JSON object: {{"category": "...", "subcategory": "..."}}' -) - -_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9 _\-()]") -_FALLBACK_CATEGORY = "Uncategorized" -_FALLBACK_SUBCATEGORY = "General" - - -def resolve_root_folder_label( - document: Document, connector: SearchSourceConnector | None -) -> str: - if connector is not None: - return _CONNECTOR_TYPE_LABEL.get( - connector.connector_type, str(connector.connector_type) - ) - return _DOCTYPE_TO_CONNECTOR_LABEL.get( - document.document_type, str(document.document_type) - ) - - -def resolve_date_folder(document: Document) -> str: - ts = document.updated_at or document.created_at - if ts is None: - ts = datetime.now(UTC) - return ts.strftime("%Y-%m-%d") - - -def sanitize_category_folder_name( - value: str | None, fallback: str = _FALLBACK_CATEGORY -) -> str: - if not value or not value.strip(): - return fallback - cleaned = _SAFE_NAME_RE.sub("", value.strip()) - cleaned = " ".join(cleaned.split()) - if not cleaned: - return fallback - return cleaned[:50] - - -async def _resolve_document_text( - session: AsyncSession, - document: Document, -) -> str: - """Build the best available text representation for taxonomy generation. - - Prefers ``document.content``; falls back to joining the first few chunks - when content is empty or too short to be useful. - """ - text = (document.content or "").strip() - if len(text) >= 100: - return text[:_MAX_CONTENT_CHARS] - - stmt = ( - select(Chunk.content) - .where(Chunk.document_id == document.id) - .order_by(Chunk.position, Chunk.id) - .limit(_MAX_CHUNKS_FOR_CONTEXT) - ) - result = await session.execute(stmt) - chunk_texts = [row[0] for row in result.all() if row[0]] - if chunk_texts: - combined = "\n\n".join(chunk_texts) - return combined[:_MAX_CONTENT_CHARS] - - return text[:_MAX_CONTENT_CHARS] - - -def _get_cached_taxonomy(document: Document) -> tuple[str, str] | None: - """Return (category, subcategory) from document metadata cache, or None.""" - meta = document.document_metadata - if not isinstance(meta, dict): - return None - cat = meta.get("ai_sort_category") - subcat = meta.get("ai_sort_subcategory") - if cat and subcat and isinstance(cat, str) and isinstance(subcat, str): - return cat, subcat - return None - - -def _set_cached_taxonomy(document: Document, category: str, subcategory: str) -> None: - """Persist the AI taxonomy on document metadata for deterministic re-sorts.""" - meta = dict(document.document_metadata or {}) - meta["ai_sort_category"] = category - meta["ai_sort_subcategory"] = subcategory - document.document_metadata = meta - - -async def generate_ai_taxonomy( - title: str, - summary_or_content: str, - llm, -) -> tuple[str, str]: - """Return (category, subcategory) using a single structured LLM call.""" - text = (summary_or_content or "").strip() - if not text: - return _FALLBACK_CATEGORY, _FALLBACK_SUBCATEGORY - - if len(text) > _MAX_CONTENT_CHARS: - text = text[:_MAX_CONTENT_CHARS] - - prompt = _CATEGORY_PROMPT.format(title=title or "Untitled", summary=text) - try: - result = await llm.ainvoke([HumanMessage(content=prompt)]) - raw = result.content.strip() - if raw.startswith("```"): - raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip() - parsed = json.loads(raw) - category = sanitize_category_folder_name( - parsed.get("category"), _FALLBACK_CATEGORY - ) - subcategory = sanitize_category_folder_name( - parsed.get("subcategory"), _FALLBACK_SUBCATEGORY - ) - return category, subcategory - except Exception: - logger.warning("AI taxonomy generation failed, using fallback", exc_info=True) - return _FALLBACK_CATEGORY, _FALLBACK_SUBCATEGORY - - -def _build_path_segments( - root_label: str, - date_label: str, - category: str, - subcategory: str, -) -> list[dict]: - return [ - {"name": root_label, "metadata": {"ai_sort": True, "ai_sort_level": 1}}, - {"name": date_label, "metadata": {"ai_sort": True, "ai_sort_level": 2}}, - {"name": category, "metadata": {"ai_sort": True, "ai_sort_level": 3}}, - {"name": subcategory, "metadata": {"ai_sort": True, "ai_sort_level": 4}}, - ] - - -async def _resolve_taxonomy( - session: AsyncSession, - document: Document, - llm, -) -> tuple[str, str]: - """Return (category, subcategory), reusing cached values when available.""" - cached = _get_cached_taxonomy(document) - if cached is not None: - return cached - - content_text = await _resolve_document_text(session, document) - category, subcategory = await generate_ai_taxonomy( - document.title, content_text, llm - ) - _set_cached_taxonomy(document, category, subcategory) - return category, subcategory - - -async def ai_sort_document( - session: AsyncSession, - document: Document, - llm, -) -> Document: - """Sort a single document into the 4-level AI folder hierarchy.""" - connector: SearchSourceConnector | None = None - if document.connector_id is not None: - connector = await session.get(SearchSourceConnector, document.connector_id) - - root_label = resolve_root_folder_label(document, connector) - date_label = resolve_date_folder(document) - - category, subcategory = await _resolve_taxonomy(session, document, llm) - - segments = _build_path_segments(root_label, date_label, category, subcategory) - - leaf_folder = await ensure_folder_hierarchy_with_depth_validation( - session, - document.workspace_id, - segments, - ) - - document.folder_id = leaf_folder.id - await session.flush() - return document - - -async def ai_sort_all_documents( - session: AsyncSession, - workspace_id: int, - llm, -) -> tuple[int, int]: - """Sort all documents in a workspace. Returns (sorted_count, failed_count).""" - stmt = ( - select(Document) - .where(Document.workspace_id == workspace_id) - .options(selectinload(Document.connector)) - ) - result = await session.execute(stmt) - documents = list(result.scalars().all()) - - sorted_count = 0 - failed_count = 0 - - for doc in documents: - try: - connector = doc.connector - root_label = resolve_root_folder_label(doc, connector) - date_label = resolve_date_folder(doc) - - category, subcategory = await _resolve_taxonomy(session, doc, llm) - segments = _build_path_segments( - root_label, date_label, category, subcategory - ) - - leaf_folder = await ensure_folder_hierarchy_with_depth_validation( - session, - workspace_id, - segments, - ) - doc.folder_id = leaf_folder.id - sorted_count += 1 - except Exception: - logger.error("Failed to AI-sort document %s", doc.id, exc_info=True) - failed_count += 1 - - await session.commit() - logger.info( - "AI sort complete for workspace=%d: sorted=%d, failed=%d", - workspace_id, - sorted_count, - failed_count, - ) - return sorted_count, failed_count diff --git a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py index c936b0080..3e26050ef 100644 --- a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py +++ b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py @@ -4,7 +4,6 @@ import asyncio import contextlib import logging import os -import time from uuid import UUID from app.celery_app import celery_app @@ -1395,108 +1394,3 @@ async def _index_uploaded_folder_files_async( if notification_id is not None: _stop_heartbeat(notification_id) - -# ===== AI File Sort tasks ===== - -AI_SORT_LOCK_TTL_SECONDS = 600 # 10 minutes -_ai_sort_redis = None - - -def _get_ai_sort_redis(): - import redis - - global _ai_sort_redis - if _ai_sort_redis is None: - _ai_sort_redis = redis.from_url(config.REDIS_APP_URL, decode_responses=True) - return _ai_sort_redis - - -def _ai_sort_lock_key(workspace_id: int) -> str: - return f"ai_sort:workspace:{workspace_id}:lock" - - -@celery_app.task(name="ai_sort_search_space", bind=True, max_retries=1) -def ai_sort_workspace_task(self, workspace_id: int, user_id: str): - """Full AI sort for all documents in a workspace.""" - return run_async_celery_task( - lambda: _ai_sort_workspace_async(workspace_id, user_id) - ) - - -async def _ai_sort_workspace_async(workspace_id: int, user_id: str): - r = _get_ai_sort_redis() - lock_key = _ai_sort_lock_key(workspace_id) - - if not r.set(lock_key, "running", nx=True, ex=AI_SORT_LOCK_TTL_SECONDS): - logger.info( - "AI sort already running for workspace=%d, skipping", - workspace_id, - ) - return - - t_start = time.perf_counter() - try: - from app.services.ai_file_sort_service import ai_sort_all_documents - from app.services.llm_service import get_agent_llm - - async with get_celery_session_maker()() as session: - llm = await get_agent_llm(session, workspace_id, disable_streaming=True) - if llm is None: - logger.warning( - "No LLM configured for workspace=%d, skipping AI sort", - workspace_id, - ) - return - - sorted_count, failed_count = await ai_sort_all_documents( - session, workspace_id, llm - ) - elapsed = time.perf_counter() - t_start - logger.info( - "AI sort workspace=%d done in %.1fs: sorted=%d failed=%d", - workspace_id, - elapsed, - sorted_count, - failed_count, - ) - finally: - r.delete(lock_key) - - -@celery_app.task( - name="ai_sort_document", bind=True, max_retries=2, default_retry_delay=10 -) -def ai_sort_document_task(self, workspace_id: int, user_id: str, document_id: int): - """Incremental AI sort for a single document after indexing.""" - return run_async_celery_task( - lambda: _ai_sort_document_async(workspace_id, user_id, document_id) - ) - - -async def _ai_sort_document_async(workspace_id: int, user_id: str, document_id: int): - from app.db import Document - from app.services.ai_file_sort_service import ai_sort_document - from app.services.llm_service import get_agent_llm - - async with get_celery_session_maker()() as session: - document = await session.get(Document, document_id) - if document is None: - logger.warning("Document %d not found, skipping AI sort", document_id) - return - - llm = await get_agent_llm(session, workspace_id, disable_streaming=True) - if llm is None: - logger.warning( - "No LLM for workspace=%d, skipping AI sort of doc=%d", - workspace_id, - document_id, - ) - return - - await ai_sort_document(session, document, llm) - await session.commit() - logger.info( - "AI sorted document=%d into workspace=%d", - document_id, - workspace_id, - ) diff --git a/surfsense_backend/tests/unit/observability/test_helpers.py b/surfsense_backend/tests/unit/observability/test_helpers.py index d81a7ed50..00e51e467 100644 --- a/surfsense_backend/tests/unit/observability/test_helpers.py +++ b/surfsense_backend/tests/unit/observability/test_helpers.py @@ -35,7 +35,6 @@ def _disable_otel(monkeypatch: pytest.MonkeyPatch): ("cleanup_stale_indexing_notifications", "cleanup"), ("reconcile_pending_stripe_credit_purchases", "reconcile"), ("check_periodic_schedules", "check"), - ("ai_sort_search_space", "ai"), ("index_notion_pages", "index"), ("index_github_repos", "index"), ("index_google_drive_files", "index"), diff --git a/surfsense_backend/tests/unit/services/test_ai_file_sort_service.py b/surfsense_backend/tests/unit/services/test_ai_file_sort_service.py deleted file mode 100644 index 860c2ffa2..000000000 --- a/surfsense_backend/tests/unit/services/test_ai_file_sort_service.py +++ /dev/null @@ -1,275 +0,0 @@ -"""Unit tests for AI file sort service: folder label resolution, date extraction, category sanitization.""" - -from datetime import UTC, datetime -from unittest.mock import AsyncMock, MagicMock - -import pytest - -pytestmark = pytest.mark.unit - - -# ── resolve_root_folder_label ── - - -def _make_document(document_type: str, connector_id=None): - doc = MagicMock() - doc.document_type = document_type - doc.connector_id = connector_id - return doc - - -def _make_connector(connector_type: str): - conn = MagicMock() - conn.connector_type = connector_type - return conn - - -def test_root_label_uses_connector_type_when_available(): - from app.services.ai_file_sort_service import resolve_root_folder_label - - doc = _make_document("FILE", connector_id=1) - conn = _make_connector("GOOGLE_DRIVE_CONNECTOR") - assert resolve_root_folder_label(doc, conn) == "Google Drive" - - -def test_root_label_falls_back_to_document_type(): - from app.services.ai_file_sort_service import resolve_root_folder_label - - doc = _make_document("SLACK_CONNECTOR") - assert resolve_root_folder_label(doc, None) == "Slack" - - -def test_root_label_unknown_doctype_returns_raw_value(): - from app.services.ai_file_sort_service import resolve_root_folder_label - - doc = _make_document("UNKNOWN_TYPE") - assert resolve_root_folder_label(doc, None) == "UNKNOWN_TYPE" - - -# ── resolve_date_folder ── - - -def test_date_folder_from_updated_at(): - from app.services.ai_file_sort_service import resolve_date_folder - - doc = MagicMock() - doc.updated_at = datetime(2025, 3, 15, 10, 30, 0, tzinfo=UTC) - doc.created_at = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) - assert resolve_date_folder(doc) == "2025-03-15" - - -def test_date_folder_falls_back_to_created_at(): - from app.services.ai_file_sort_service import resolve_date_folder - - doc = MagicMock() - doc.updated_at = None - doc.created_at = datetime(2024, 12, 25, 23, 59, 0, tzinfo=UTC) - assert resolve_date_folder(doc) == "2024-12-25" - - -def test_date_folder_both_none_uses_today(): - from app.services.ai_file_sort_service import resolve_date_folder - - doc = MagicMock() - doc.updated_at = None - doc.created_at = None - result = resolve_date_folder(doc) - today = datetime.now(UTC).strftime("%Y-%m-%d") - assert result == today - - -# ── sanitize_category_folder_name ── - - -def test_sanitize_normal_value(): - from app.services.ai_file_sort_service import sanitize_category_folder_name - - assert sanitize_category_folder_name("Machine Learning") == "Machine Learning" - - -def test_sanitize_strips_special_chars(): - from app.services.ai_file_sort_service import sanitize_category_folder_name - - assert sanitize_category_folder_name("Tax/Reports!") == "TaxReports" - - -def test_sanitize_empty_returns_fallback(): - from app.services.ai_file_sort_service import sanitize_category_folder_name - - assert sanitize_category_folder_name("") == "Uncategorized" - assert sanitize_category_folder_name(None) == "Uncategorized" - - -def test_sanitize_truncates_long_names(): - from app.services.ai_file_sort_service import sanitize_category_folder_name - - long_name = "A" * 100 - result = sanitize_category_folder_name(long_name) - assert len(result) <= 50 - - -# ── generate_ai_taxonomy ── - - -@pytest.mark.asyncio -async def test_generate_ai_taxonomy_parses_json(): - from app.services.ai_file_sort_service import generate_ai_taxonomy - - mock_llm = AsyncMock() - mock_result = MagicMock() - mock_result.content = '{"category": "Science", "subcategory": "Physics"}' - mock_llm.ainvoke.return_value = mock_result - - cat, sub = await generate_ai_taxonomy( - "Physics Paper", "Some science document about physics", mock_llm - ) - assert cat == "Science" - assert sub == "Physics" - - -@pytest.mark.asyncio -async def test_generate_ai_taxonomy_handles_markdown_code_block(): - from app.services.ai_file_sort_service import generate_ai_taxonomy - - mock_llm = AsyncMock() - mock_result = MagicMock() - mock_result.content = ( - '```json\n{"category": "Finance", "subcategory": "Tax Reports"}\n```' - ) - mock_llm.ainvoke.return_value = mock_result - - cat, sub = await generate_ai_taxonomy("Tax Doc", "A tax report document", mock_llm) - assert cat == "Finance" - assert sub == "Tax Reports" - - -@pytest.mark.asyncio -async def test_generate_ai_taxonomy_includes_title_in_prompt(): - from app.services.ai_file_sort_service import generate_ai_taxonomy - - mock_llm = AsyncMock() - mock_result = MagicMock() - mock_result.content = '{"category": "Engineering", "subcategory": "Backend"}' - mock_llm.ainvoke.return_value = mock_result - - await generate_ai_taxonomy("API Design Guide", "content about REST APIs", mock_llm) - - prompt_text = mock_llm.ainvoke.call_args[0][0][0].content - assert "API Design Guide" in prompt_text - assert "content about REST APIs" in prompt_text - - -@pytest.mark.asyncio -async def test_generate_ai_taxonomy_fallback_on_error(): - from app.services.ai_file_sort_service import generate_ai_taxonomy - - mock_llm = AsyncMock() - mock_llm.ainvoke.side_effect = RuntimeError("LLM down") - - cat, sub = await generate_ai_taxonomy("Title", "some content", mock_llm) - assert cat == "Uncategorized" - assert sub == "General" - - -@pytest.mark.asyncio -async def test_generate_ai_taxonomy_fallback_on_empty_content(): - from app.services.ai_file_sort_service import generate_ai_taxonomy - - mock_llm = AsyncMock() - cat, sub = await generate_ai_taxonomy("Title", "", mock_llm) - assert cat == "Uncategorized" - assert sub == "General" - mock_llm.ainvoke.assert_not_called() - - -@pytest.mark.asyncio -async def test_generate_ai_taxonomy_fallback_on_invalid_json(): - from app.services.ai_file_sort_service import generate_ai_taxonomy - - mock_llm = AsyncMock() - mock_result = MagicMock() - mock_result.content = "not valid json at all" - mock_llm.ainvoke.return_value = mock_result - - cat, sub = await generate_ai_taxonomy("Title", "some content", mock_llm) - assert cat == "Uncategorized" - assert sub == "General" - - -# ── taxonomy caching ── - - -def test_get_cached_taxonomy_returns_none_when_no_metadata(): - from app.services.ai_file_sort_service import _get_cached_taxonomy - - doc = MagicMock() - doc.document_metadata = None - assert _get_cached_taxonomy(doc) is None - - -def test_get_cached_taxonomy_returns_none_when_keys_missing(): - from app.services.ai_file_sort_service import _get_cached_taxonomy - - doc = MagicMock() - doc.document_metadata = {"some_other_key": "value"} - assert _get_cached_taxonomy(doc) is None - - -def test_get_cached_taxonomy_returns_cached_values(): - from app.services.ai_file_sort_service import _get_cached_taxonomy - - doc = MagicMock() - doc.document_metadata = { - "ai_sort_category": "Finance", - "ai_sort_subcategory": "Tax Reports", - } - assert _get_cached_taxonomy(doc) == ("Finance", "Tax Reports") - - -def test_set_cached_taxonomy_persists_on_metadata(): - from app.services.ai_file_sort_service import _set_cached_taxonomy - - doc = MagicMock() - doc.document_metadata = {"existing_key": "keep_me"} - _set_cached_taxonomy(doc, "Science", "Physics") - assert doc.document_metadata["ai_sort_category"] == "Science" - assert doc.document_metadata["ai_sort_subcategory"] == "Physics" - assert doc.document_metadata["existing_key"] == "keep_me" - - -def test_set_cached_taxonomy_creates_metadata_when_none(): - from app.services.ai_file_sort_service import _set_cached_taxonomy - - doc = MagicMock() - doc.document_metadata = None - _set_cached_taxonomy(doc, "Engineering", "Backend") - assert doc.document_metadata == { - "ai_sort_category": "Engineering", - "ai_sort_subcategory": "Backend", - } - - -# ── _build_path_segments ── - - -def test_build_path_segments_structure(): - from app.services.ai_file_sort_service import _build_path_segments - - segments = _build_path_segments("Google Drive", "2025-03-15", "Science", "Physics") - assert len(segments) == 4 - assert segments[0] == { - "name": "Google Drive", - "metadata": {"ai_sort": True, "ai_sort_level": 1}, - } - assert segments[1] == { - "name": "2025-03-15", - "metadata": {"ai_sort": True, "ai_sort_level": 2}, - } - assert segments[2] == { - "name": "Science", - "metadata": {"ai_sort": True, "ai_sort_level": 3}, - } - assert segments[3] == { - "name": "Physics", - "metadata": {"ai_sort": True, "ai_sort_level": 4}, - } diff --git a/surfsense_backend/tests/unit/services/test_ai_sort_task_dedupe.py b/surfsense_backend/tests/unit/services/test_ai_sort_task_dedupe.py deleted file mode 100644 index 2acfde1ad..000000000 --- a/surfsense_backend/tests/unit/services/test_ai_sort_task_dedupe.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Unit tests for AI sort task Redis deduplication lock.""" - -from unittest.mock import MagicMock, patch - -import pytest - -pytestmark = pytest.mark.unit - - -def test_lock_key_format(): - from app.tasks.celery_tasks.document_tasks import _ai_sort_lock_key - - key = _ai_sort_lock_key(42) - assert key == "ai_sort:workspace:42:lock" - - -def test_lock_prevents_duplicate_run(): - """When the Redis lock already exists, the task should skip execution.""" - - mock_redis = MagicMock() - mock_redis.set.return_value = False # Lock already held - - with ( - patch( - "app.tasks.celery_tasks.document_tasks._get_ai_sort_redis", - return_value=mock_redis, - ), - patch( - "app.tasks.celery_tasks.document_tasks.get_celery_session_maker" - ) as mock_session_maker, - ): - import asyncio - - from app.tasks.celery_tasks.document_tasks import _ai_sort_workspace_async - - loop = asyncio.new_event_loop() - try: - loop.run_until_complete(_ai_sort_workspace_async(1, "user-123")) - finally: - loop.close() - - # Session maker should never be called since lock was not acquired - mock_session_maker.assert_not_called() diff --git a/surfsense_backend/tests/unit/services/test_folder_hierarchy.py b/surfsense_backend/tests/unit/services/test_folder_hierarchy.py index 9077f6b0e..d444b1a7d 100644 --- a/surfsense_backend/tests/unit/services/test_folder_hierarchy.py +++ b/surfsense_backend/tests/unit/services/test_folder_hierarchy.py @@ -49,8 +49,8 @@ async def test_creates_missing_folders_in_chain(): session.flush = mock_flush segments = [ - {"name": "Slack", "metadata": {"ai_sort": True, "ai_sort_level": 1}}, - {"name": "2025-03-15", "metadata": {"ai_sort": True, "ai_sort_level": 2}}, + {"name": "Slack", "metadata": {"source": "slack"}}, + {"name": "2025-03-15", "metadata": {"source": "slack"}}, ] result = await ensure_folder_hierarchy_with_depth_validation( diff --git a/surfsense_web/components/documents/DocumentsFilters.tsx b/surfsense_web/components/documents/DocumentsFilters.tsx index 86e91ee42..cda34eb48 100644 --- a/surfsense_web/components/documents/DocumentsFilters.tsx +++ b/surfsense_web/components/documents/DocumentsFilters.tsx @@ -1,6 +1,5 @@ "use client"; -import { IconBinaryTree, IconBinaryTreeFilled } from "@tabler/icons-react"; import { FolderPlus, ListFilter, Search, Upload, X } from "lucide-react"; import { useTranslations } from "next-intl"; import React, { useCallback, useMemo, useRef, useState } from "react"; @@ -9,12 +8,10 @@ import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Spinner } from "@/components/ui/spinner"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels"; -import { cn } from "@/lib/utils"; import { getDocumentTypeIcon } from "./DocumentTypeIcon"; export function DocumentsFilters({ @@ -24,9 +21,6 @@ export function DocumentsFilters({ onToggleType, activeTypes, onCreateFolder, - aiSortEnabled = false, - aiSortBusy = false, - onToggleAiSort, onUploadClick, }: { typeCounts: Partial>; @@ -35,9 +29,6 @@ export function DocumentsFilters({ onToggleType: (type: DocumentTypeEnum, checked: boolean) => void; activeTypes: DocumentTypeEnum[]; onCreateFolder?: () => void; - aiSortEnabled?: boolean; - aiSortBusy?: boolean; - onToggleAiSort?: () => void; onUploadClick?: () => void; }) { const t = useTranslations("documents"); @@ -77,7 +68,7 @@ export function DocumentsFilters({ return (
- {/* New Folder + AI Sort + Filter Toggle Group */} + {/* New Folder + Filter Toggle Group */} {onCreateFolder && ( @@ -97,46 +88,6 @@ export function DocumentsFilters({ )} - {onToggleAiSort && ( - - - { - e.preventDefault(); - onToggleAiSort(); - }} - aria-label={aiSortEnabled ? "Disable AI sort" : "Enable AI sort"} - aria-pressed={aiSortEnabled} - > - {aiSortBusy ? ( - - ) : aiSortEnabled ? ( - - ) : ( - - )} - - - - {aiSortBusy - ? "AI sort in progress..." - : aiSortEnabled - ? "AI sort active — click to disable" - : "Enable AI sort"} - - - )} - diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx index 7c076e99a..7f1c2186e 100644 --- a/surfsense_web/components/documents/FolderTreeView.tsx +++ b/surfsense_web/components/documents/FolderTreeView.tsx @@ -93,8 +93,6 @@ export function FolderTreeView({ const [openContextMenuId, setOpenContextMenuId] = useState(null); - const [manuallyCollapsedAiIds, setManuallyCollapsedAiIds] = useState>(new Set()); - // Single subscription for rename state — derived boolean passed to each FolderNode const [renamingFolderId, setRenamingFolderId] = useAtom(renamingFolderIdAtom); const handleStartRename = useCallback( @@ -103,38 +101,6 @@ export function FolderTreeView({ ); const handleCancelRename = useCallback(() => setRenamingFolderId(null), [setRenamingFolderId]); - const aiSortFolderLevels = useMemo(() => { - const map = new Map(); - for (const f of folders) { - if (f.metadata?.ai_sort === true && typeof f.metadata?.ai_sort_level === "number") { - map.set(f.id, f.metadata.ai_sort_level as number); - } - } - return map; - }, [folders]); - - const handleToggleExpand = useCallback( - (folderId: number) => { - const aiLevel = aiSortFolderLevels.get(folderId); - if (aiLevel !== undefined && aiLevel < 4) { - // AI-auto-expanded folder: only toggle the manual-collapse set. - // Calling onToggleExpand would add it to expandedIds and fight auto-expand. - setManuallyCollapsedAiIds((prev) => { - const next = new Set(prev); - if (next.has(folderId)) { - next.delete(folderId); - } else { - next.add(folderId); - } - return next; - }); - return; - } - onToggleExpand(folderId); - }, - [onToggleExpand, aiSortFolderLevels] - ); - const effectiveActiveTypes = useMemo(() => { if ( activeTypes.includes("FILE" as DocumentTypeEnum) && @@ -280,14 +246,9 @@ export function FolderTreeView({ function renderLevel(parentId: number | null, depth: number): React.ReactNode[] { const key = parentId ?? "root"; - const childFolders = (foldersByParent[key] ?? []).slice().sort((a, b) => { - const aIsDate = a.metadata?.ai_sort === true && a.metadata?.ai_sort_level === 2; - const bIsDate = b.metadata?.ai_sort === true && b.metadata?.ai_sort_level === 2; - if (aIsDate && bIsDate) { - return b.name.localeCompare(a.name); - } - return a.position.localeCompare(b.position); - }); + const childFolders = (foldersByParent[key] ?? []) + .slice() + .sort((a, b) => a.position.localeCompare(b.position)); const visibleFolders = hasDescendantMatch ? childFolders.filter((f) => hasDescendantMatch[f.id]) : childFolders; @@ -317,14 +278,7 @@ export function FolderTreeView({ }; const isSearchAutoExpanded = !!searchQuery && !!hasDescendantMatch?.[f.id]; - const isAiAutoExpandCandidate = - f.metadata?.ai_sort === true && - typeof f.metadata?.ai_sort_level === "number" && - (f.metadata.ai_sort_level as number) < 4; - const isManuallyCollapsed = manuallyCollapsedAiIds.has(f.id); - const isExpanded = isManuallyCollapsed - ? isSearchAutoExpanded - : expandedIds.has(f.id) || isSearchAutoExpanded || isAiAutoExpandCandidate; + const isExpanded = expandedIds.has(f.id) || isSearchAutoExpanded; nodes.push( searchSpaces?.find((s) => s.id === searchSpaceId), - [searchSpaces, searchSpaceId] - ); - const aiSortEnabled = activeSearchSpace?.ai_file_sort_enabled ?? false; - const [aiSortBusy, setAiSortBusy] = useState(false); - const [aiSortConfirmOpen, setAiSortConfirmOpen] = useState(false); - - const handleToggleAiSort = useCallback(() => { - if (aiSortEnabled) { - // Disable: just update the setting, no confirmation needed - setAiSortBusy(true); - searchSpacesApiService - .updateSearchSpace({ id: searchSpaceId, data: { ai_file_sort_enabled: false } }) - .then(() => { - refetchSearchSpaces(); - toast.success("AI file sorting disabled"); - }) - .catch(() => toast.error("Failed to disable AI file sorting")) - .finally(() => setAiSortBusy(false)); - } else { - setAiSortConfirmOpen(true); - } - }, [aiSortEnabled, searchSpaceId, refetchSearchSpaces]); - - const handleConfirmEnableAiSort = useCallback(() => { - setAiSortConfirmOpen(false); - setAiSortBusy(true); - searchSpacesApiService - .updateSearchSpace({ id: searchSpaceId, data: { ai_file_sort_enabled: true } }) - .then(() => searchSpacesApiService.triggerAiSort(searchSpaceId)) - .then(() => { - refetchSearchSpaces(); - toast.success("AI file sorting enabled — organizing your documents in the background"); - }) - .catch(() => toast.error("Failed to enable AI file sorting")) - .finally(() => setAiSortBusy(false)); - }, [searchSpaceId, refetchSearchSpaces]); - const handleWatchLocalFolder = useCallback(async () => { const api = window.electronAPI; if (!api?.selectFolder) return; @@ -1251,9 +1208,6 @@ function AuthenticatedDocumentsSidebarBase({ onToggleType={onToggleType} activeTypes={activeTypes} onCreateFolder={() => handleCreateFolder(null)} - aiSortEnabled={aiSortEnabled} - aiSortBusy={aiSortBusy} - onToggleAiSort={handleToggleAiSort} />
@@ -1588,22 +1542,6 @@ function AuthenticatedDocumentsSidebarBase({ - - - - Enable AI File Sorting? - - All documents in this search space will be organized into folders by connector type, - date, and AI-generated categories. New documents will also be sorted automatically. - You can disable this at any time. - - - - Cancel - Enable - - - ); @@ -1935,7 +1873,6 @@ function AnonymousDocumentsSidebar({ onToggleType={() => {}} activeTypes={[]} onCreateFolder={() => gate("create folders")} - aiSortEnabled={false} onUploadClick={handleAnonUploadClick} />
diff --git a/surfsense_web/contracts/types/search-space.types.ts b/surfsense_web/contracts/types/search-space.types.ts index acc0be07a..3c75da894 100644 --- a/surfsense_web/contracts/types/search-space.types.ts +++ b/surfsense_web/contracts/types/search-space.types.ts @@ -11,7 +11,6 @@ export const searchSpace = z.object({ api_access_enabled: z.boolean().optional().default(false), qna_custom_instructions: z.string().nullable(), shared_memory_md: z.string().nullable().optional(), - ai_file_sort_enabled: z.boolean().optional().default(false), member_count: z.number(), is_owner: z.boolean(), }); @@ -58,7 +57,6 @@ export const updateSearchSpaceRequest = z.object({ citations_enabled: true, api_access_enabled: true, qna_custom_instructions: true, - ai_file_sort_enabled: true, }) .partial(), }); diff --git a/surfsense_web/lib/apis/search-spaces-api.service.ts b/surfsense_web/lib/apis/search-spaces-api.service.ts index 3477a70f4..09e1894b2 100644 --- a/surfsense_web/lib/apis/search-spaces-api.service.ts +++ b/surfsense_web/lib/apis/search-spaces-api.service.ts @@ -139,17 +139,6 @@ class SearchSpacesApiService { return baseApiService.delete(`/api/v1/workspaces/${request.id}`, deleteSearchSpaceResponse); }; - /** - * Trigger AI file sorting for all documents in a search space - */ - triggerAiSort = async (searchSpaceId: number) => { - return baseApiService.post( - `/api/v1/workspaces/${searchSpaceId}/ai-sort`, - z.object({ message: z.string() }), - {} - ); - }; - /** * Leave a search space (remove own membership) * This is used by non-owners to leave a shared search space