feat(ai): catalogue d'outils lecture & recherche (#79 phase C)
CI / lint (push) Successful in 59s
CI / security (push) Successful in 49s
CI / test (push) Successful in 1m24s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m24s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 59s
CI / security (push) Successful in 49s
CI / test (push) Successful in 1m24s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m24s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
This commit is contained in:
@@ -44,6 +44,100 @@ class ListTagsInput(BaseModel):
|
||||
vault: str | None = Field(None, description="Vault name or 'all' (default: all accessible)")
|
||||
|
||||
|
||||
class ListAllFilesInput(BaseModel):
|
||||
"""List every file of a vault (optionally under a subdirectory)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
dir: str = Field("", description="Vault-relative directory path (empty = vault root)")
|
||||
limit: int = Field(200, ge=1, le=2000, description="Maximum number of files")
|
||||
recursive: bool = Field(True, description="Recurse into subdirectories")
|
||||
|
||||
|
||||
class ReadFileRawInput(BaseModel):
|
||||
"""Read a text file's raw content (no HTML rendering)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative file path")
|
||||
|
||||
|
||||
class GetBacklinksInput(BaseModel):
|
||||
"""List files linking to a target file via wikilinks."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the target file")
|
||||
|
||||
|
||||
class ListBackupsInput(BaseModel):
|
||||
"""List available backup versions of a file."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative file path")
|
||||
|
||||
|
||||
class DiffBackupInput(BaseModel):
|
||||
"""Unified diff between a backup version and another version or the current file."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative file path")
|
||||
version: int = Field(..., description="Backup timestamp used as the old/left side")
|
||||
compare_with: int | None = Field(
|
||||
None,
|
||||
description="Backup timestamp used as the new/right side (omit to compare with the current file)",
|
||||
)
|
||||
|
||||
|
||||
class GetGraphInput(BaseModel):
|
||||
"""Graph data (nodes and edges) for a vault or directory."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field("", description="Vault-relative directory path to focus on (empty = root)")
|
||||
depth: int = Field(1, ge=0, le=3, description="Expansion depth")
|
||||
scope: str = Field("directory", description="'directory' for a subtree, 'full' for the whole vault")
|
||||
tag: str = Field("", description="Only include files carrying this tag")
|
||||
|
||||
|
||||
class SearchAdvancedInput(BaseModel):
|
||||
"""Advanced full-text search (TF-IDF, facets, operators)."""
|
||||
|
||||
q: str = Field("", description="Query (supports tag:, vault:, title:, path:, ext: operators)")
|
||||
vault: str = Field("all", description="Vault name or 'all'")
|
||||
tag: str | None = Field(None, description="Optional comma-separated tag filter")
|
||||
limit: int = Field(50, ge=1, le=200, description="Maximum number of results")
|
||||
offset: int = Field(0, ge=0, description="Pagination offset")
|
||||
sort: str = Field("relevance", description="Sort by 'relevance' or 'modified'")
|
||||
case_sensitive: bool = Field(False, description="Match case")
|
||||
whole_word: bool = Field(False, description="Match whole words only")
|
||||
regex: bool = Field(False, description="Treat query as a regular expression")
|
||||
include_paths: str | None = Field(None, description="Comma-separated glob patterns to include")
|
||||
exclude_paths: str | None = Field(None, description="Comma-separated glob patterns to exclude")
|
||||
created: str | None = Field(None, description="Created date filter (>date, <date, date..date)")
|
||||
modified: str | None = Field(None, description="Modified date filter (>date, <date, date..date, <Nd)")
|
||||
size: str | None = Field(None, description="Size filter (>size, <size, size..size, e.g. >1MB)")
|
||||
|
||||
|
||||
class SearchPathsInput(BaseModel):
|
||||
"""Search files and directories by path substring."""
|
||||
|
||||
q: str = Field(..., min_length=1, description="Path substring to search for")
|
||||
vault: str = Field("all", description="Vault name or 'all'")
|
||||
|
||||
|
||||
class SuggestTagsInput(BaseModel):
|
||||
"""Suggest tags matching a prefix."""
|
||||
|
||||
q: str = Field(..., min_length=1, description="Tag prefix (with or without leading '#')")
|
||||
vault: str = Field("all", description="Vault name or 'all'")
|
||||
limit: int = Field(10, ge=1, le=50, description="Maximum number of suggestions")
|
||||
|
||||
|
||||
class ListRecentInput(BaseModel):
|
||||
"""List recently opened (or modified) files."""
|
||||
|
||||
vault: str | None = Field(None, description="Optional single-vault filter")
|
||||
limit: int = Field(20, ge=1, le=200, description="Maximum number of files")
|
||||
mode: str = Field("opened", description="'opened' (history) or 'modified'")
|
||||
|
||||
|
||||
class ToolResult(BaseModel):
|
||||
"""Uniform result returned by :func:`backend.tools.registry.call_tool`."""
|
||||
|
||||
|
||||
+194
-5
@@ -1,4 +1,4 @@
|
||||
"""Built-in tool services (Phase 0 — read/search).
|
||||
"""Built-in tool services (Phase 0 + Phase C — read/search catalogue).
|
||||
|
||||
These functions are the single source of truth consumed by both the in-app
|
||||
assistant and the MCP server. They delegate to the shared business-logic
|
||||
@@ -11,18 +11,35 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from backend.indexer import get_backlinks as _get_backlinks
|
||||
from backend.indexer import get_vault_names
|
||||
from backend.services.backups import diff_backup as _diff_backup
|
||||
from backend.services.backups import list_backup_files as _list_backup_files
|
||||
from backend.services.files import read_file_text
|
||||
from backend.services.graph import get_graph as _get_graph
|
||||
from backend.services.recent import list_recent as _list_recent
|
||||
from backend.services.search import advanced_search_vaults, search_vaults
|
||||
from backend.services.search import list_tags as _list_tags
|
||||
from backend.services.search import search_vaults
|
||||
from backend.services.vaults import browse_directory, list_accessible_vaults
|
||||
from backend.services.search import search_paths as _search_paths
|
||||
from backend.services.vaults import browse_directory, list_accessible_vaults, list_all_files
|
||||
from backend.tools.context import ToolContext, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import (
|
||||
DiffBackupInput,
|
||||
GetBacklinksInput,
|
||||
GetGraphInput,
|
||||
ListAllFilesInput,
|
||||
ListBackupsInput,
|
||||
ListDirectoryInput,
|
||||
ListRecentInput,
|
||||
ListTagsInput,
|
||||
ListVaultsInput,
|
||||
ReadFileInput,
|
||||
ReadFileRawInput,
|
||||
SearchAdvancedInput,
|
||||
SearchFulltextInput,
|
||||
SearchPathsInput,
|
||||
SuggestTagsInput,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.service")
|
||||
@@ -31,6 +48,9 @@ logger = logging.getLogger("obsigate.tools.service")
|
||||
TOOL_MAX_READ_BYTES = 200_000
|
||||
|
||||
|
||||
# ── C1. Vaults / navigation ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_vaults",
|
||||
description="List the vaults the current user is allowed to access.",
|
||||
@@ -61,6 +81,21 @@ def list_directory(ctx: ToolContext, params: ListDirectoryInput) -> list[dict[st
|
||||
]
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_all_files",
|
||||
description="List every file of a vault (optionally under a subdirectory), newest first.",
|
||||
input_model=ListAllFilesInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def list_all_files_tool(ctx: ToolContext, params: ListAllFilesInput) -> dict[str, Any]:
|
||||
"""Return a flat list of files with metadata."""
|
||||
return list_all_files(params.vault, dir=params.dir, limit=params.limit, recursive=params.recursive)
|
||||
|
||||
|
||||
# ── C2. Content reading ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool(
|
||||
name="read_file",
|
||||
description="Read the text content of a file inside a vault (secrets redacted).",
|
||||
@@ -78,6 +113,81 @@ def read_file(ctx: ToolContext, params: ReadFileInput) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
@tool(
|
||||
name="read_file_raw",
|
||||
description="Read a file's raw text content, without the size cap (secrets redacted).",
|
||||
input_model=ReadFileRawInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def read_file_raw(ctx: ToolContext, params: ReadFileRawInput) -> dict[str, Any]:
|
||||
"""Return the full (redacted) raw text of a vault file."""
|
||||
data = read_file_text(params.vault, params.path, redact=True, max_bytes=None)
|
||||
return {"vault": data["vault"], "path": data["path"], "raw": data["content"]}
|
||||
|
||||
|
||||
@tool(
|
||||
name="get_backlinks",
|
||||
description="List files that link to a target file via [[wikilinks]].",
|
||||
input_model=GetBacklinksInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def get_backlinks(ctx: ToolContext, params: GetBacklinksInput) -> list[dict[str, Any]]:
|
||||
"""Return backlinks, filtered to accessible vaults."""
|
||||
backlinks = _get_backlinks(params.vault, params.path)
|
||||
return [b for b in backlinks if ctx.has_vault_access(b["vault"])]
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_backups",
|
||||
description="List the available backup versions of a file (newest first).",
|
||||
input_model=ListBackupsInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def list_backups(ctx: ToolContext, params: ListBackupsInput) -> dict[str, Any]:
|
||||
"""Return the backup versions of a vault file."""
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"backups": _list_backup_files(params.vault, params.path),
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="diff_backup",
|
||||
description="Show a unified diff between a backup version and another version or the current file.",
|
||||
input_model=DiffBackupInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def diff_backup(ctx: ToolContext, params: DiffBackupInput) -> dict[str, Any]:
|
||||
"""Return the unified diff for a backup version."""
|
||||
return _diff_backup(params.vault, params.path, params.version, params.compare_with)
|
||||
|
||||
|
||||
@tool(
|
||||
name="get_graph",
|
||||
description="Return the graph (nodes and edges: parent/child + wikilinks) of a vault or directory.",
|
||||
input_model=GetGraphInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def get_graph(ctx: ToolContext, params: GetGraphInput) -> dict[str, Any]:
|
||||
"""Return graph data for a vault subtree or the whole vault."""
|
||||
return _get_graph(
|
||||
params.vault,
|
||||
path=params.path,
|
||||
depth=params.depth,
|
||||
scope=params.scope,
|
||||
tag=params.tag,
|
||||
)
|
||||
|
||||
|
||||
# ── C3. Search ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool(
|
||||
name="search_fulltext",
|
||||
description="Full-text search across one vault or all accessible vaults.",
|
||||
@@ -90,6 +200,45 @@ def search_fulltext(ctx: ToolContext, params: SearchFulltextInput) -> list[dict[
|
||||
return [r for r in payload["results"] if ctx.has_vault_access(r["vault"])]
|
||||
|
||||
|
||||
@tool(
|
||||
name="search_advanced",
|
||||
description="Advanced full-text search with operators, filters and facets.",
|
||||
input_model=SearchAdvancedInput,
|
||||
risk=ToolRisk.READ,
|
||||
)
|
||||
def search_advanced(ctx: ToolContext, params: SearchAdvancedInput) -> list[dict[str, Any]]:
|
||||
"""Return advanced search results, filtered to accessible vaults."""
|
||||
payload = advanced_search_vaults(
|
||||
params.q,
|
||||
vault=params.vault,
|
||||
tag=params.tag,
|
||||
limit=params.limit,
|
||||
offset=params.offset,
|
||||
sort=params.sort,
|
||||
case_sensitive=params.case_sensitive,
|
||||
whole_word=params.whole_word,
|
||||
regex=params.regex,
|
||||
include_paths=params.include_paths,
|
||||
exclude_paths=params.exclude_paths,
|
||||
created=params.created,
|
||||
modified=params.modified,
|
||||
size=params.size,
|
||||
)
|
||||
return [r for r in payload["results"] if ctx.has_vault_access(r["vault"])]
|
||||
|
||||
|
||||
@tool(
|
||||
name="search_paths",
|
||||
description="Search files and directories by path substring.",
|
||||
input_model=SearchPathsInput,
|
||||
risk=ToolRisk.READ,
|
||||
)
|
||||
def search_paths(ctx: ToolContext, params: SearchPathsInput) -> list[dict[str, Any]]:
|
||||
"""Return path matches, filtered to accessible vaults."""
|
||||
payload = _search_paths(params.q, vault=params.vault)
|
||||
return [r for r in payload["results"] if ctx.has_vault_access(r["vault"])]
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_tags",
|
||||
description="List tags with their occurrence counts for a vault or all accessible vaults.",
|
||||
@@ -102,8 +251,6 @@ def list_tags(ctx: ToolContext, params: ListTagsInput) -> list[dict[str, Any]]:
|
||||
ctx.require_vault_access(params.vault)
|
||||
return [{"tag": tag, "count": count} for tag, count in _list_tags(params.vault).items()]
|
||||
|
||||
from backend.indexer import get_vault_names
|
||||
|
||||
merged: dict[str, int] = {}
|
||||
for name in get_vault_names():
|
||||
if not ctx.has_vault_access(name):
|
||||
@@ -111,3 +258,45 @@ def list_tags(ctx: ToolContext, params: ListTagsInput) -> list[dict[str, Any]]:
|
||||
for tag, count in _list_tags(name).items():
|
||||
merged[tag] = merged.get(tag, 0) + count
|
||||
return [{"tag": tag, "count": count} for tag, count in sorted(merged.items(), key=lambda x: -x[1])]
|
||||
|
||||
|
||||
@tool(
|
||||
name="suggest_tags",
|
||||
description="Suggest tags matching a prefix, across accessible vaults.",
|
||||
input_model=SuggestTagsInput,
|
||||
risk=ToolRisk.READ,
|
||||
)
|
||||
def suggest_tags(ctx: ToolContext, params: SuggestTagsInput) -> list[dict[str, Any]]:
|
||||
"""Return tag suggestions, restricted to accessible vaults."""
|
||||
from backend.search import suggest_tags as _suggest
|
||||
|
||||
if params.vault and params.vault != "all":
|
||||
ctx.require_vault_access(params.vault)
|
||||
return _suggest(params.q, vault_filter=params.vault, limit=params.limit)
|
||||
|
||||
merged: dict[str, int] = {}
|
||||
for name in get_vault_names():
|
||||
if not ctx.has_vault_access(name):
|
||||
continue
|
||||
for item in _suggest(params.q, vault_filter=name, limit=params.limit):
|
||||
merged[item["tag"]] = merged.get(item["tag"], 0) + item["count"]
|
||||
ordered = sorted(merged.items(), key=lambda x: -x[1])
|
||||
return [{"tag": tag, "count": count} for tag, count in ordered[: params.limit]]
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_recent",
|
||||
description="List the current user's recently opened (or modified) files.",
|
||||
input_model=ListRecentInput,
|
||||
risk=ToolRisk.READ,
|
||||
)
|
||||
def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
|
||||
"""Return recent files for the calling user."""
|
||||
user_vaults = ctx.user.get("_token_vaults") or ctx.user.get("vaults", [])
|
||||
return _list_recent(
|
||||
ctx.username,
|
||||
user_vaults,
|
||||
vault=params.vault,
|
||||
limit=params.limit,
|
||||
mode=params.mode,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user