354 lines
14 KiB
Python
354 lines
14 KiB
Python
"""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))
|