Files
ObsiGate/backend/routers/search.py
T

354 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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(),
partial(search_vaults, q, vault, tag, limit, offset),
)
@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, <date, date..date)"),
modified: str | None = Query(None, description="Modified date filter (>date, <date, date..date, <Nd)"),
size: str | None = Query(None, description="Size filter (>size, <size, size..size, e.g. >1MB, <10KB)"),
semantic: bool = Query(False, description="Fuse TF-IDF with semantic embeddings (RRF)"),
current_user=Depends(require_auth),
):
"""Advanced full-text search with TF-IDF scoring, facets, and pagination.
Supports advanced query operators:
- ``tag:<name>`` or ``#<name>`` — filter by tag
- ``vault:<name>`` — filter by vault
- ``title:<text>`` — filter by title substring
- ``path:<text>`` — filter by path substring
- ``ext:<type>`` — 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 ``<mark>``-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))