"""Search, suggest, graph & index-reload endpoints (ROADMAP #85, tranche 5). Handlers déplacés depuis :mod:`backend.main` sans changement de comportement : mêmes chemins, mêmes modèles de réponse (déménagés dans :mod:`backend.schemas`), mêmes dépendances d'authentification. La logique métier vit déjà dans :mod:`backend.services.search`, :mod:`backend.search`, :mod:`backend.services.graph` et :mod:`backend.services.mutations`. Adaptations strictement équivalentes : - Le pool ``_search_executor`` de ``main`` vit désormais dans :mod:`backend.search_executor` (même dimensionnement, même cycle de vie géré par le lifespan de ``main``) : accès via :func:`get_search_executor`. """ import asyncio import logging from functools import partial from pathlib import Path from fastapi import APIRouter, Body, Depends, HTTPException, Query from backend.audit import log_file_save from backend.auth.middleware import check_vault_access, require_admin, require_auth from backend.indexer import get_vault_data, reload_index, update_single_file from backend.schemas import ( AdvancedSearchResponse, GraphResponse, ReloadResponse, ReplaceResponse, SearchResponse, SuggestResponse, TagsResponse, TagSuggestResponse, TreeSearchResponse, VaultPathsResponse, VaultStatsResponse, ) from backend.search import suggest_tags, suggest_titles from backend.search_executor import get_search_executor from backend.services.graph import get_graph as service_get_graph from backend.services.mutations import ( replace_in_files as service_replace_in_files, ) from backend.services.search import advanced_search_vaults, list_paths, search_paths, search_vaults from backend.services.search import list_tags as service_list_tags from backend.sse import sse_manager logger = logging.getLogger("obsigate") router = APIRouter(tags=["search"]) @router.get("/api/search", response_model=SearchResponse) async def api_search( q: str = Query("", description="Search query"), vault: str = Query("all", description="Vault filter"), tag: str | None = Query(None, description="Tag filter"), limit: int = Query(50, ge=1, le=200, description="Results per page"), offset: int = Query(0, ge=0, description="Pagination offset"), current_user=Depends(require_auth), ): """Full-text search across vaults with relevance scoring. Supports combining free-text queries with tag filters. Results are ranked by a multi-factor scoring algorithm. Pagination via ``limit`` and ``offset`` (defaults preserve backward compat). Args: q: Free-text search string. vault: Vault name or ``"all"`` to search everywhere. tag: Comma-separated tag names to require. limit: Max results per page (1–200). offset: Pagination offset. Returns: ``SearchResponse`` with ranked results and snippets. """ loop = asyncio.get_event_loop() # Fetch the full result set (capped at DEFAULT_SEARCH_LIMIT internally) and # paginate in the shared service so routes and tools share the same logic. return await loop.run_in_executor( get_search_executor(), # #194 : filtre par vault accessible AVANT pagination ("*" sans les # dossiers persos) — sinon un user voyait les notes des autres. # #196 : les documents reçus par partage dirigé sont ajoutés aux # résultats (vault virtuel "home-/Partage"). partial( search_vaults, q, vault, tag, limit, offset, is_allowed=lambda v: check_vault_access(v, current_user), username=current_user["username"], ), ) @router.get("/api/tags", response_model=TagsResponse) async def api_tags(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)): """Return all unique tags with occurrence counts. Args: vault: Optional vault name to restrict tag aggregation. Returns: ``TagsResponse`` with tags sorted by descending count. """ return {"vault_filter": vault, "tags": service_list_tags(vault)} @router.get("/api/tree-search", response_model=TreeSearchResponse) async def api_tree_search( q: str = Query("", description="Search query"), vault: str = Query("all", description="Vault filter"), current_user=Depends(require_auth), ): """Search for files and directories in the tree structure using pre-built index. Uses the in-memory path index for instant filtering without filesystem access. Args: q: Search string to match against file/directory paths. vault: Vault name or "all" to search everywhere. Returns: ``TreeSearchResponse`` with matching paths. """ return search_paths(q, vault) @router.get("/api/vault/{vault_name}/paths", response_model=VaultPathsResponse) async def api_vault_paths( vault_name: str, limit: int = Query(5000, ge=1, le=20000, description="Maximum number of indexed paths to return"), current_user=Depends(require_auth), ): """Return a flat list of every indexed file and directory in a vault. Used by the AI assistant ``@`` mention menu to filter paths instantly on the client (one request instead of one per keystroke). Args: vault_name: Name of the vault. limit: Maximum number of entries returned. Returns: ``VaultPathsResponse`` with the vault's indexed paths. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") return list_paths(vault_name, limit=limit) @router.get("/api/search/advanced", response_model=AdvancedSearchResponse) async def api_advanced_search( q: str = Query("", description="Advanced search query (supports tag:, vault:, title:, path:, ext: operators)"), vault: str = Query("all", description="Vault filter"), tag: str | None = Query(None, description="Comma-separated tag filter"), limit: int = Query(50, ge=1, le=200, description="Results per page"), offset: int = Query(0, ge=0, description="Pagination offset"), sort: str = Query("relevance", description="Sort by 'relevance' or 'modified'"), case_sensitive: bool = Query(False, description="Match case"), whole_word: bool = Query(False, description="Match whole words only"), regex: bool = Query(False, description="Treat query as regex"), include_paths: str | None = Query(None, description="Comma-separated glob patterns to include"), exclude_paths: str | None = Query(None, description="Comma-separated glob patterns to exclude"), created: str | None = Query(None, description="Created date filter (>date, `` or ``#`` — filter by tag - ``vault:`` — filter by vault - ``title:`` — filter by title substring - ``path:`` — filter by path substring - ``ext:`` — filter by file extension - ``created:>2024-01-01`` — filter by creation date - ``modified:<7d`` or ``modified:2024-01-01..2024-06-01`` — filter by modification date - ``size:>1MB`` or ``size:100KB..1MB`` — filter by file size - Remaining text is scored using TF-IDF with accent normalization. - Toggles: case_sensitive, whole_word, regex - Path filters: include_paths, exclude_paths (glob patterns) - ``semantic=true`` — fuse the TF-IDF ranking with the semantic (embedding) ranking via Reciprocal Rank Fusion and expose ``semantic_score`` per result. Results include ````-highlighted snippets and faceted tag/vault counts. """ loop = asyncio.get_event_loop() search_fn = partial(advanced_search_vaults, q, vault=vault, tag=tag, limit=limit, offset=offset, sort=sort, case_sensitive=case_sensitive, whole_word=whole_word, regex=regex, include_paths=include_paths, exclude_paths=exclude_paths, created=created, modified=modified, size=size, semantic=semantic) try: return await loop.run_in_executor(get_search_executor(), search_fn) except ValueError as e: raise HTTPException(400, str(e)) from e @router.post("/api/search/replace", response_model=ReplaceResponse) async def api_search_replace( body: dict = Body(...), current_user=Depends(require_auth), ): """Find and replace across vault files.""" query = body.get("query", "") replacement = body.get("replacement", "") vault_filter = body.get("vault", "all") case_sensitive = body.get("case_sensitive", False) whole_word = body.get("whole_word", False) regex_mode = body.get("regex", False) include_paths = body.get("include_paths") exclude_paths = body.get("exclude_paths") replace_all = body.get("replace_all", False) dry_run = body.get("dry_run", not replace_all) if not query: raise HTTPException(400, "Query is required") result = service_replace_in_files( query, replacement, vault=vault_filter, case_sensitive=case_sensitive, whole_word=whole_word, regex=regex_mode, include_paths=include_paths, exclude_paths=exclude_paths, replace_all=replace_all, dry_run=dry_run, is_vault_allowed=lambda v: check_vault_access(v, current_user), ) if dry_run: return result # Side effects for applied replacements (audit + incremental index). for match in result.get("replaced", []): log_file_save(current_user["username"], match["vault"], match["path"], match.get("size", 0)) vault_data = get_vault_data(match["vault"]) if vault_data: abs_path = str(Path(vault_data["path"]) / match["path"]) await update_single_file(match["vault"], abs_path) return result @router.get("/api/suggest", response_model=SuggestResponse) async def api_suggest( q: str = Query("", description="Prefix to search for in file titles"), vault: str = Query("all", description="Vault filter"), limit: int = Query(10, ge=1, le=50, description="Max suggestions"), current_user=Depends(require_auth), ): """Suggest file titles matching a prefix (accent-insensitive). Used for autocomplete in the search input. Args: q: User-typed prefix (minimum 2 characters). vault: Vault name or ``"all"``. limit: Max number of suggestions. Returns: ``SuggestResponse`` with matching file title suggestions. """ suggestions = suggest_titles(q, vault_filter=vault, limit=limit) return {"query": q, "suggestions": suggestions} @router.get("/api/tags/suggest", response_model=TagSuggestResponse) async def api_tags_suggest( q: str = Query("", description="Prefix to search for in tags"), vault: str = Query("all", description="Vault filter"), limit: int = Query(10, ge=1, le=50, description="Max suggestions"), current_user=Depends(require_auth), ): """Suggest tags matching a prefix (accent-insensitive). Used for autocomplete when typing ``tag:`` or ``#`` in the search input. Args: q: User-typed prefix (with or without ``#``, minimum 2 characters). vault: Vault name or ``"all"``. limit: Max number of suggestions. Returns: ``TagSuggestResponse`` with matching tag suggestions and counts. """ suggestions = suggest_tags(q, vault_filter=vault, limit=limit) return {"query": q, "suggestions": suggestions} @router.get("/api/index/reload", response_model=ReloadResponse) async def api_reload(current_user=Depends(require_admin)): """Force a full re-index of all configured vaults. Returns: ``ReloadResponse`` with per-vault file and tag counts. """ stats = await reload_index() await sse_manager.broadcast("index_reloaded", { "vaults": list(stats.keys()), "stats": stats, }) return {"status": "ok", "vaults": stats} @router.get("/api/graph/{vault_name}", response_model=GraphResponse) async def api_graph( vault_name: str, path: str = Query("", description="Relative path to focus on"), depth: int = Query(1, ge=0, le=3, description="How many levels deep to expand"), scope: str = Query("directory", description="'directory' (default) or 'full' for entire vault"), tag: str = Query("", description="Filter: only show files with this tag"), current_user=Depends(require_auth), ): """Return graph data (nodes and edges) for a vault or directory. Nodes represent files and directories. Edges represent parent-child relationships and wikilinks between markdown files. Args: vault_name: Name of the vault. path: Relative directory path to focus on (empty = root). depth: Expansion depth (0 = only direct children, 1-3 = deeper). scope: 'directory' for subtree, 'full' for entire vault. tag: Optional tag filter (only files with this tag appear). Returns: ``GraphResponse`` with nodes and edges. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") return service_get_graph(vault_name, path=path, depth=depth, scope=scope, tag=tag) @router.get("/api/index/reload/{vault_name}", response_model=VaultStatsResponse) async def api_reload_vault(vault_name: str, current_user=Depends(require_admin)): """Force a re-index of a single vault. Args: vault_name: Name of the vault to reindex. Returns: Dict with vault statistics. """ try: from backend.indexer import reload_single_vault stats = await reload_single_vault(vault_name) await sse_manager.broadcast("vault_reloaded", { "vault": vault_name, "stats": stats, }) return {"status": "ok", "vault": vault_name, "stats": stats} except ValueError as e: raise HTTPException(status_code=404, detail=str(e))