feat(ai): services partages A2 + SSE streaming B4 + confirmations UI B5 (#79)
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
This commit is contained in:
+67
-2
@@ -98,6 +98,59 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[
|
||||
}
|
||||
|
||||
|
||||
def _execute_confirmed(
|
||||
ctx: ToolContext,
|
||||
confirm_pending: dict[str, Any],
|
||||
convo: list[dict[str, Any]],
|
||||
executed: list[ToolCallRecord],
|
||||
on_tool_call: Callable[[ToolCallRecord], None] | None,
|
||||
) -> None:
|
||||
"""Apply a previously-paused mutating tool call and feed its result back.
|
||||
|
||||
The pending payload is the ``error`` object emitted by a ``confirmation``
|
||||
event. The assistant tool-call message is expected to already be in
|
||||
``convo`` (it is part of the snapshot returned with the confirmation).
|
||||
"""
|
||||
from backend.ai_chat import ToolCall
|
||||
|
||||
error = confirm_pending.get("error", confirm_pending)
|
||||
name = error.get("tool")
|
||||
arguments = error.get("arguments") or {}
|
||||
call_id = error.get("id") or "call_pending"
|
||||
|
||||
if not name:
|
||||
raise ToolError("Malformed confirmation payload", code="invalid_confirmation")
|
||||
|
||||
# Make sure the assistant tool-call message is present in the snapshot.
|
||||
if not any(
|
||||
m.get("role") == "assistant" and any(
|
||||
tc.get("id") == call_id for tc in (m.get("tool_calls") or [])
|
||||
)
|
||||
for m in convo
|
||||
):
|
||||
convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)]))
|
||||
|
||||
try:
|
||||
result = call_tool(name, ctx, arguments, confirm=True)
|
||||
payload = result.data
|
||||
ok = True
|
||||
except ToolError as e:
|
||||
payload = e.to_dict()
|
||||
ok = False
|
||||
|
||||
record = ToolCallRecord(name=name, arguments=arguments, ok=ok, result=payload)
|
||||
executed.append(record)
|
||||
if on_tool_call is not None:
|
||||
on_tool_call(record)
|
||||
|
||||
convo.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": name,
|
||||
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
|
||||
})
|
||||
|
||||
|
||||
async def run_agent(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
@@ -106,6 +159,8 @@ async def run_agent(
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
||||
on_tool_call: Callable[[ToolCallRecord], None] | None = None,
|
||||
resume_messages: list[dict[str, Any]] | None = None,
|
||||
confirm_pending: dict[str, Any] | None = None,
|
||||
) -> AgentResult:
|
||||
"""Run the tool-calling loop until completion.
|
||||
|
||||
@@ -119,6 +174,10 @@ async def run_agent(
|
||||
pass ``[]`` to disable tool calling (plain chat).
|
||||
max_iterations: Hard cap on LLM round-trips.
|
||||
on_tool_call: Optional callback invoked after each executed tool call.
|
||||
resume_messages: Conversation snapshot from a paused run (returned with
|
||||
a ``confirmation`` event). When set, the loop resumes from it.
|
||||
confirm_pending: Pending mutating tool call to apply before resuming
|
||||
(two-step propose/apply).
|
||||
|
||||
Returns:
|
||||
An :class:`AgentResult`. ``stopped`` is ``done``, ``max_iterations`` or
|
||||
@@ -129,9 +188,12 @@ async def run_agent(
|
||||
if tools is None:
|
||||
tools = get_tool_schemas(scope=ToolScope.IN_APP)
|
||||
|
||||
convo = [dict(m) for m in messages]
|
||||
convo = [dict(m) for m in (resume_messages if resume_messages is not None else messages)]
|
||||
executed: list[ToolCallRecord] = []
|
||||
|
||||
if confirm_pending:
|
||||
_execute_confirmed(ctx, confirm_pending, convo, executed, on_tool_call)
|
||||
|
||||
for iteration in range(1, max_iterations + 1):
|
||||
response = await llm(convo, tools)
|
||||
|
||||
@@ -153,13 +215,16 @@ async def run_agent(
|
||||
ok = True
|
||||
except ToolConfirmationRequired as e:
|
||||
logger.info(f"Agent paused: confirmation required for '{call.name}'")
|
||||
pending = e.to_dict()
|
||||
# Include the tool-call id so the client can echo it back.
|
||||
pending["error"]["id"] = call.id
|
||||
return AgentResult(
|
||||
content=response.content or "",
|
||||
messages=convo,
|
||||
tool_calls=executed,
|
||||
iterations=iteration,
|
||||
stopped=STOP_CONFIRMATION_REQUIRED,
|
||||
pending=e.to_dict(),
|
||||
pending=pending,
|
||||
)
|
||||
except ToolError as e:
|
||||
payload = e.to_dict()
|
||||
|
||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -232,3 +233,108 @@ async def _post_json(url: str, headers: dict[str, str] | None, payload: dict[str
|
||||
resp = await client.post(url, headers=headers or {}, json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ── Streaming (SSE token stream) ────────────────────────────────────────
|
||||
|
||||
|
||||
async def stream_completion(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
provider: str | None = None,
|
||||
model: str | None = None,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 4096,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Yield content deltas from a chat completion as they arrive.
|
||||
|
||||
Only text content is streamed (no tool calling): this backs the plain
|
||||
``/api/ai/bookslm/chat`` endpoint. The tool-calling ``/agent`` endpoint
|
||||
keeps using :func:`chat_completion` because tool calls need the complete
|
||||
response before they can be executed.
|
||||
"""
|
||||
cfg = _get_provider_config(provider) # type: ignore[arg-type]
|
||||
resolved_model = model or cfg["model"]
|
||||
|
||||
if cfg["name"] == "gemini":
|
||||
stream = _gemini_stream(messages, resolved_model, temperature, max_tokens)
|
||||
else:
|
||||
stream = _openai_stream(messages, cfg, resolved_model, temperature, max_tokens)
|
||||
|
||||
async for chunk in stream:
|
||||
yield chunk
|
||||
|
||||
|
||||
async def _openai_stream(
|
||||
messages: list[dict[str, Any]],
|
||||
cfg: dict[str, Any],
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream an OpenAI-compatible ``/chat/completions`` response."""
|
||||
headers = _build_headers(cfg)
|
||||
url = f"{cfg['base_url']}/chat/completions"
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0) as client, client.stream("POST", url, headers=headers, json=payload) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
if not line or not line.startswith("data:"):
|
||||
continue
|
||||
raw = line[5:].strip()
|
||||
if raw == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
yield content
|
||||
|
||||
|
||||
async def _gemini_stream(
|
||||
messages: list[dict[str, Any]],
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream Gemini's ``streamGenerateContent`` response (SSE)."""
|
||||
cfg = PROVIDERS["gemini"]
|
||||
system, contents = _gemini_contents(messages)
|
||||
payload: dict[str, Any] = {
|
||||
"contents": contents,
|
||||
"generationConfig": {"temperature": temperature, "maxOutputTokens": max_tokens},
|
||||
}
|
||||
if system:
|
||||
payload["system_instruction"] = {"parts": [{"text": system}]}
|
||||
|
||||
url = f"{cfg['base_url']}/models/{model}:streamGenerateContent?alt=sse&key={cfg['api_key']}"
|
||||
async with httpx.AsyncClient(timeout=120.0) as client, client.stream("POST", url, json=payload) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
if not line or not line.startswith("data:"):
|
||||
continue
|
||||
raw = line[5:].strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
for candidate in chunk.get("candidates") or []:
|
||||
for part in candidate.get("content", {}).get("parts", []):
|
||||
text = part.get("text")
|
||||
if text:
|
||||
yield text
|
||||
|
||||
+53
-60
@@ -3,13 +3,14 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.agent.loop import run_agent
|
||||
from backend.ai_chat import chat_completion
|
||||
from backend.ai_chat import chat_completion, stream_completion
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.bookslm import (
|
||||
build_general_system_prompt,
|
||||
@@ -63,6 +64,16 @@ class BooksLMChatRequest(BaseModel):
|
||||
default=None,
|
||||
description="Model name to use for this request. If not set, uses the provider's default model.",
|
||||
)
|
||||
confirm: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Pending tool confirmation to apply (two-step propose/apply). "
|
||||
"Shape: the ``error`` object of a previous ``confirmation`` event.",
|
||||
)
|
||||
confirm_messages: list[dict[str, Any]] | None = Field(
|
||||
default=None,
|
||||
description="Conversation snapshot returned alongside a ``confirmation`` event, "
|
||||
"echoed back to resume the agent run.",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_mode(mode: str | None) -> str:
|
||||
@@ -169,66 +180,37 @@ async def api_bookslm_chat(
|
||||
"""
|
||||
system_prompt = _resolve_system_prompt(req, current_user)
|
||||
|
||||
# Call AI provider
|
||||
from backend.ai import DEFAULT_PROVIDER, PROVIDERS, _call_deepseek_openrouter, _call_gemini
|
||||
|
||||
# Build messages with conversation history
|
||||
messages_text = ""
|
||||
if req.conversation_history:
|
||||
for turn in req.conversation_history:
|
||||
role = turn.get("role", "user")
|
||||
content = turn.get("content", "")
|
||||
if role == "user":
|
||||
messages_text += f"\n\nUtilisateur : {content}"
|
||||
elif role == "assistant":
|
||||
messages_text += f"\n\nAssistant : {content}"
|
||||
|
||||
# Current message
|
||||
user_prompt = req.message
|
||||
if messages_text:
|
||||
user_prompt = f"Historique de la conversation :{messages_text}\n\nQuestion actuelle : {req.message}"
|
||||
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
|
||||
for turn in req.conversation_history:
|
||||
role = turn.get("role")
|
||||
content = turn.get("content", "")
|
||||
if role in ("user", "assistant") and content:
|
||||
messages.append({"role": role, "content": content})
|
||||
messages.append({"role": "user", "content": req.message})
|
||||
|
||||
async def generate_sse():
|
||||
try:
|
||||
# Resolve provider: explicit override wins, else default.
|
||||
# Fall back to first available if the requested one isn't configured.
|
||||
cfg_name = (req.provider or DEFAULT_PROVIDER).lower()
|
||||
if cfg_name not in PROVIDERS or not PROVIDERS[cfg_name].get("api_key"):
|
||||
# Try next available provider
|
||||
for pname, pcfg in PROVIDERS.items():
|
||||
if pcfg.get("api_key") and pname != "gemini":
|
||||
cfg_name = pname
|
||||
break
|
||||
else:
|
||||
# No provider available at all
|
||||
err = "Aucun fournisseur AI configuré (clés API manquantes)"
|
||||
error_data = json.dumps({"error": err}, ensure_ascii=False)
|
||||
yield f"event: error\ndata: {error_data}\n\n"
|
||||
return
|
||||
# Resolve provider: explicit override wins, else first available.
|
||||
cfg_name = _resolve_provider_name(req.provider)
|
||||
if cfg_name is None:
|
||||
err = "Aucun fournisseur AI configuré (clés API manquantes)"
|
||||
error_data = json.dumps({"error": err}, ensure_ascii=False)
|
||||
yield f"event: error\ndata: {error_data}\n\n"
|
||||
return
|
||||
|
||||
# Optional per-request model override
|
||||
original_model = None
|
||||
if req.model and cfg_name in PROVIDERS:
|
||||
original_model = PROVIDERS[cfg_name].get("model")
|
||||
PROVIDERS[cfg_name]["model"] = req.model
|
||||
try:
|
||||
if cfg_name == "gemini":
|
||||
response = await _call_gemini(user_prompt, system_prompt, temperature=0.3, max_tokens=4096)
|
||||
else:
|
||||
response = await _call_deepseek_openrouter(
|
||||
user_prompt, system_prompt,
|
||||
provider=cfg_name,
|
||||
temperature=0.3,
|
||||
max_tokens=4096,
|
||||
)
|
||||
finally:
|
||||
# Restore the original model so other calls aren't affected
|
||||
if original_model is not None and cfg_name in PROVIDERS:
|
||||
PROVIDERS[cfg_name]["model"] = original_model
|
||||
|
||||
# Send the full response as a single SSE event
|
||||
data = json.dumps({"token": response, "provider": cfg_name, "model": req.model or PROVIDERS.get(cfg_name, {}).get("model", "")}, ensure_ascii=False)
|
||||
yield f"event: message\ndata: {data}\n\n"
|
||||
# Stream token deltas as they arrive from the provider.
|
||||
async for token in stream_completion(
|
||||
messages,
|
||||
provider=cfg_name,
|
||||
model=req.model,
|
||||
temperature=0.3,
|
||||
max_tokens=4096,
|
||||
):
|
||||
data = json.dumps(
|
||||
{"token": token, "provider": cfg_name, "model": req.model or ""},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
yield f"event: message\ndata: {data}\n\n"
|
||||
yield "event: done\ndata: {}\n\n"
|
||||
except Exception as e:
|
||||
logger.error(f"BooksLM chat error: {e}")
|
||||
@@ -260,7 +242,9 @@ async def api_bookslm_agent(
|
||||
Same context as ``/chat`` but the model may call tools (read/search the
|
||||
vault) through the shared tool layer. Emits one ``tool`` event per executed
|
||||
tool call, then a final ``message`` event. Mutating tools pause the run with
|
||||
a ``confirmation`` event (two-step propose/apply).
|
||||
a ``confirmation`` event (two-step propose/apply) carrying the pending call
|
||||
and the conversation snapshot; the client resumes by echoing them back in
|
||||
``confirm`` / ``confirm_messages``.
|
||||
"""
|
||||
system_prompt = _resolve_system_prompt(req, current_user)
|
||||
|
||||
@@ -295,7 +279,13 @@ async def api_bookslm_agent(
|
||||
yield f"event: error\ndata: {error_data}\n\n"
|
||||
return
|
||||
|
||||
result = await run_agent(messages, ctx=ctx, llm=_llm)
|
||||
result = await run_agent(
|
||||
messages,
|
||||
ctx=ctx,
|
||||
llm=_llm,
|
||||
resume_messages=req.confirm_messages,
|
||||
confirm_pending=req.confirm,
|
||||
)
|
||||
|
||||
for rec in result.tool_calls:
|
||||
payload = json.dumps(
|
||||
@@ -305,7 +295,10 @@ async def api_bookslm_agent(
|
||||
yield f"event: tool\ndata: {payload}\n\n"
|
||||
|
||||
if result.stopped == "confirmation_required":
|
||||
pending = json.dumps(result.pending or {}, ensure_ascii=False)
|
||||
pending = json.dumps(
|
||||
{"pending": result.pending or {}, "messages": result.messages},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
yield f"event: confirmation\ndata: {pending}\n\n"
|
||||
else:
|
||||
data = json.dumps(
|
||||
|
||||
+37
-136
@@ -20,7 +20,7 @@ from typing import Any
|
||||
import frontmatter
|
||||
import mistune
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
@@ -99,12 +99,15 @@ from backend.schemas import (
|
||||
)
|
||||
from backend.search import (
|
||||
advanced_search,
|
||||
get_all_tags,
|
||||
init_inverted_index,
|
||||
search,
|
||||
suggest_tags,
|
||||
suggest_titles,
|
||||
)
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.files import read_raw_file
|
||||
from backend.services.search import list_tags as service_list_tags
|
||||
from backend.services.search import search_vaults
|
||||
from backend.services.vaults import browse_directory, list_accessible_vaults
|
||||
from backend.vault_settings import (
|
||||
get_vault_setting,
|
||||
update_vault_setting,
|
||||
@@ -737,6 +740,12 @@ def _custom_openapi():
|
||||
|
||||
app.openapi = _custom_openapi # type: ignore[method-assign]
|
||||
|
||||
|
||||
@app.exception_handler(ServiceError)
|
||||
async def _service_error_handler(request: Request, exc: ServiceError):
|
||||
"""Map shared-layer domain errors to HTTP responses (``{"detail": ...}``)."""
|
||||
return JSONResponse(status_code=exc.status, content={"detail": exc.message})
|
||||
|
||||
# GZip compression — reduces bandwidth by ~70% for text responses
|
||||
# Custom wrapper: skip compression for SSE streams (/api/events)
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
@@ -749,8 +758,16 @@ class SSESafeGZipMiddleware(GZipMiddleware):
|
||||
GZip buffering breaks incremental streaming required by SSE.
|
||||
We detect SSE endpoints by path and bypass compression entirely.
|
||||
"""
|
||||
# SSE endpoints that must not be buffered by GZip.
|
||||
_SSE_PATHS = (
|
||||
"/api/events",
|
||||
"/api/admin/stream",
|
||||
"/api/ai/bookslm/chat",
|
||||
"/api/ai/bookslm/agent",
|
||||
)
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] == "http" and scope.get("path") in ("/api/events", "/api/admin/stream"):
|
||||
if scope["type"] == "http" and scope.get("path") in self._SSE_PATHS:
|
||||
# Bypass GZip: passthrough directly to the inner app
|
||||
await self.app(scope, receive, send)
|
||||
else:
|
||||
@@ -864,11 +881,13 @@ def _content_disposition(disposition: str, filename: str) -> str:
|
||||
return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
|
||||
|
||||
|
||||
def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
|
||||
def _resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
||||
"""Resolve a relative path safely within the vault root.
|
||||
|
||||
Prevents directory traversal attacks by ensuring the resolved
|
||||
absolute path is a descendant of *vault_root*.
|
||||
Thin wrapper around the shared :func:`backend.services.paths.resolve_safe_path`
|
||||
(single implementation used by both routes and tools). The raised
|
||||
:class:`ServiceError` is mapped to an ``HTTPException`` response by the
|
||||
global exception handler in this module.
|
||||
|
||||
Args:
|
||||
vault_root: The vault's root directory (absolute).
|
||||
@@ -876,34 +895,10 @@ def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
|
||||
|
||||
Returns:
|
||||
Resolved absolute ``Path``.
|
||||
|
||||
Raises:
|
||||
HTTPException(403): When the resolved path escapes the vault root.
|
||||
"""
|
||||
# Construct the full path without resolving symlinks first
|
||||
full_path = vault_root / relative_path
|
||||
|
||||
# Resolve both paths to handle symlinks
|
||||
try:
|
||||
resolved = full_path.resolve(strict=False)
|
||||
vault_resolved = vault_root.resolve(strict=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Path resolution error - vault_root: {vault_root}, relative_path: {relative_path}, error: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Path resolution error: {e!s}")
|
||||
|
||||
# Check if resolved path is within vault using string comparison (case-insensitive on Windows)
|
||||
try:
|
||||
# This will raise ValueError if resolved is not relative to vault_resolved
|
||||
resolved.relative_to(vault_resolved)
|
||||
except ValueError:
|
||||
# Try case-insensitive comparison for Windows/Docker compatibility
|
||||
resolved_str = str(resolved).lower()
|
||||
vault_str = str(vault_resolved).lower()
|
||||
if not resolved_str.startswith(vault_str):
|
||||
logger.warning(f"Path outside vault - vault: {vault_resolved}, requested: {relative_path}, resolved: {resolved}")
|
||||
raise HTTPException(status_code=403, detail="Access denied: path outside vault")
|
||||
|
||||
return resolved
|
||||
from backend.services.paths import resolve_safe_path as _service_resolve
|
||||
|
||||
return _service_resolve(vault_root, relative_path)
|
||||
|
||||
|
||||
def _backup_file(file_path: Path, vault_name: str, relative_path: str):
|
||||
@@ -1271,18 +1266,7 @@ async def api_vaults(current_user=Depends(require_auth)):
|
||||
Returns:
|
||||
List of vault summary objects filtered by user permissions.
|
||||
"""
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
result = []
|
||||
for name, data in index.items():
|
||||
if "*" in user_vaults or name in user_vaults:
|
||||
v_type = data.get("config", {}).get("type", "VAULT")
|
||||
result.append({
|
||||
"name": name,
|
||||
"file_count": len(data["files"]),
|
||||
"tag_count": len(data["tags"]),
|
||||
"type": v_type,
|
||||
})
|
||||
return result
|
||||
return list_accessible_vaults(current_user)
|
||||
|
||||
|
||||
def humanize_mtime(mtime: float) -> str:
|
||||
@@ -1516,57 +1500,7 @@ async def api_browse(vault_name: str, path: str = "", current_user=Depends(requi
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
# Path traversal protection
|
||||
target = _resolve_safe_path(vault_root, path) if path else vault_root.resolve()
|
||||
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
|
||||
|
||||
# Get vault settings for hideHiddenFiles
|
||||
settings = get_vault_setting(vault_name) or {}
|
||||
hide_hidden = settings.get("hideHiddenFiles", False)
|
||||
|
||||
items = []
|
||||
try:
|
||||
for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||||
# Skip hidden files/dirs if the setting is enabled
|
||||
if hide_hidden and entry.name.startswith("."):
|
||||
continue
|
||||
rel = str(entry.relative_to(vault_root)).replace("\\", "/")
|
||||
if entry.is_dir():
|
||||
# Count only direct children (files and subdirs) for performance
|
||||
try:
|
||||
file_count = sum(
|
||||
1 for child in entry.iterdir()
|
||||
if (not hide_hidden or not child.name.startswith("."))
|
||||
and (child.is_file() and (child.suffix.lower() in SUPPORTED_EXTENSIONS or child.name.lower() in ("dockerfile", "makefile"))
|
||||
or child.is_dir())
|
||||
)
|
||||
except PermissionError:
|
||||
file_count = 0
|
||||
items.append({
|
||||
"name": entry.name,
|
||||
"path": rel,
|
||||
"type": "directory",
|
||||
"children_count": file_count,
|
||||
})
|
||||
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
|
||||
items.append({
|
||||
"name": entry.name,
|
||||
"path": rel,
|
||||
"type": "file",
|
||||
"size": entry.stat().st_size,
|
||||
"extension": entry.suffix.lower(),
|
||||
})
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
|
||||
return {"vault": vault_name, "path": path, "items": items}
|
||||
return browse_directory(vault_name, path)
|
||||
|
||||
|
||||
# Map file extensions to highlight.js language hints
|
||||
@@ -1599,33 +1533,7 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = _resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission denied reading raw file {path}: {e}")
|
||||
raise HTTPException(status_code=403, detail=f"Permission denied: cannot read file {path}")
|
||||
except UnicodeDecodeError:
|
||||
# Binary file - try to read as binary and decode with errors='replace'
|
||||
try:
|
||||
raw = file_path.read_bytes().decode("utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading binary raw file {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Cannot read file: {e!s}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error reading raw file {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
|
||||
|
||||
return {"vault": vault_name, "path": path, "raw": raw}
|
||||
return read_raw_file(vault_name, path)
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/download", response_class=FileResponse)
|
||||
@@ -3120,18 +3028,12 @@ async def api_search(
|
||||
``SearchResponse`` with ranked results and snippets.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
# Fetch full result set (capped at DEFAULT_SEARCH_LIMIT internally)
|
||||
all_results = await loop.run_in_executor(
|
||||
# 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(
|
||||
_search_executor,
|
||||
partial(search, q, vault_filter=vault, tag_filter=tag),
|
||||
partial(search_vaults, q, vault, tag, limit, offset),
|
||||
)
|
||||
total = len(all_results)
|
||||
page = all_results[offset: offset + limit]
|
||||
return {
|
||||
"query": q, "vault_filter": vault, "tag_filter": tag,
|
||||
"count": len(page), "total": total, "offset": offset, "limit": limit,
|
||||
"results": page,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/tags", response_model=TagsResponse)
|
||||
@@ -3144,8 +3046,7 @@ async def api_tags(vault: str | None = Query(None, description="Vault filter"),
|
||||
Returns:
|
||||
``TagsResponse`` with tags sorted by descending count.
|
||||
"""
|
||||
tags = get_all_tags(vault_filter=vault)
|
||||
return {"vault_filter": vault, "tags": tags}
|
||||
return {"vault_filter": vault, "tags": service_list_tags(vault)}
|
||||
|
||||
|
||||
@app.get("/api/tree-search", response_model=TreeSearchResponse)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Domain errors shared by the reusable service layer.
|
||||
|
||||
Services are transport-agnostic: they raise :class:`ServiceError` carrying a
|
||||
stable ``code`` and an HTTP ``status`` hint. The REST layer maps it to an
|
||||
``HTTPException`` (via the global handler in ``backend.main``) while the tool
|
||||
layer maps it to a :class:`backend.tools.context.ToolError`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ServiceError(Exception):
|
||||
"""Base error for the shared business-logic services."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: str = "service_error",
|
||||
status: int = 400,
|
||||
details: dict[str, Any] | None = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.status = status
|
||||
self.details = details or {}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""File reading services shared by REST routes and the AI tool layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import get_vault_root
|
||||
|
||||
logger = logging.getLogger("obsigate.services.files")
|
||||
|
||||
|
||||
def read_raw_file(vault_name: str, path: str) -> dict[str, Any]:
|
||||
"""Return the raw text content of a vault file (no redaction)."""
|
||||
root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission denied reading raw file {path}: {e}")
|
||||
raise ServiceError(f"Permission denied: cannot read file {path}", code="permission_denied", status=403) from e
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
raw = file_path.read_bytes().decode("utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading binary raw file {path}: {e}")
|
||||
raise ServiceError(f"Cannot read file: {e!s}", code="read_error", status=500) from e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error reading raw file {path}: {e}")
|
||||
raise ServiceError(f"Error reading file: {e!s}", code="read_error", status=500) from e
|
||||
|
||||
return {"vault": vault_name, "path": path, "raw": raw}
|
||||
|
||||
|
||||
def read_file_text(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
*,
|
||||
redact: bool = True,
|
||||
max_bytes: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a vault file's text content, optionally redacted and size-capped.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404), ``file_too_large`` (413) or a read
|
||||
error (500).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
target = resolve_safe_path(root, path)
|
||||
|
||||
if not target.exists() or not target.is_file():
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
|
||||
size = target.stat().st_size
|
||||
if max_bytes is not None and size > max_bytes:
|
||||
raise ServiceError(
|
||||
f"File too large ({size} bytes > {max_bytes})",
|
||||
code="file_too_large",
|
||||
status=413,
|
||||
details={"vault": vault_name, "path": path, "size": size},
|
||||
)
|
||||
|
||||
content = target.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
if redact:
|
||||
from backend.secret_redactor import redact_file_content
|
||||
|
||||
content = redact_file_content(content, path)
|
||||
|
||||
return {"vault": vault_name, "path": path, "size": size, "content": content}
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Vault path resolution shared by the REST routes and the AI tool layer.
|
||||
|
||||
This is the single implementation of the anti path-traversal check. Routes map
|
||||
:class:`ServiceError` to ``HTTPException`` and tools map it to
|
||||
:class:`backend.tools.context.ToolError`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
|
||||
logger = logging.getLogger("obsigate.services.paths")
|
||||
|
||||
|
||||
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
||||
"""Resolve a vault-relative path, rejecting traversal outside the vault.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``path_error`` (500) when the path cannot be resolved,
|
||||
``path_outside_vault`` (403) when it escapes the vault root.
|
||||
"""
|
||||
full_path = vault_root / (relative_path or "")
|
||||
try:
|
||||
resolved = full_path.resolve(strict=False)
|
||||
root = vault_root.resolve(strict=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Path resolution error - vault_root: {vault_root}, relative_path: {relative_path}, error: {e}")
|
||||
raise ServiceError(f"Path resolution error: {e!s}", code="path_error", status=500) from e
|
||||
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
except ValueError:
|
||||
# Case-insensitive fallback for Windows / Docker path casing.
|
||||
if not str(resolved).lower().startswith(str(root).lower()):
|
||||
logger.warning(f"Path outside vault - vault: {root}, requested: {relative_path}, resolved: {resolved}")
|
||||
raise ServiceError(
|
||||
"Access denied: path outside vault",
|
||||
code="path_outside_vault",
|
||||
status=403,
|
||||
details={"path": relative_path},
|
||||
) from None
|
||||
return resolved
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Search services shared by REST routes and the AI tool layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def search_vaults(
|
||||
q: str,
|
||||
vault: str = "all",
|
||||
tag: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Full-text search with pagination, returned as the API response payload.
|
||||
|
||||
No permission filtering is applied here: callers that need it (the tool
|
||||
layer) filter the ``results`` list themselves.
|
||||
"""
|
||||
from backend.search import search
|
||||
|
||||
all_results = search(q, vault_filter=vault, tag_filter=tag)
|
||||
total = len(all_results)
|
||||
page = all_results[offset: offset + limit]
|
||||
return {
|
||||
"query": q,
|
||||
"vault_filter": vault,
|
||||
"tag_filter": tag,
|
||||
"count": len(page),
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"results": page,
|
||||
}
|
||||
|
||||
|
||||
def list_tags(vault: str | None = None) -> dict[str, int]:
|
||||
"""Return tag → count, optionally restricted to a single vault."""
|
||||
from backend.search import get_all_tags
|
||||
|
||||
return get_all_tags(vault_filter=vault)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Vault listing and directory browsing services.
|
||||
|
||||
Single source of truth consumed by both the REST routes (``/api/vaults``,
|
||||
``/api/browse/{vault}``) and the AI tool layer (``list_vaults``,
|
||||
``list_directory``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
|
||||
|
||||
def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return the vaults *user* may access, with summary metadata."""
|
||||
from backend.auth.middleware import check_vault_access
|
||||
from backend.indexer import index
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for name, data in index.items():
|
||||
if not check_vault_access(name, user):
|
||||
continue
|
||||
result.append({
|
||||
"name": name,
|
||||
"file_count": len(data.get("files", [])),
|
||||
"tag_count": len(data.get("tags", {})),
|
||||
"type": data.get("config", {}).get("type", "VAULT"),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def get_vault_root(vault_name: str) -> Path:
|
||||
"""Return the filesystem root of *vault_name* or raise ``not_found``."""
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
data = get_vault_data(vault_name)
|
||||
if not data:
|
||||
raise ServiceError(
|
||||
f"Vault '{vault_name}' not found",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name},
|
||||
)
|
||||
return Path(data["path"])
|
||||
|
||||
|
||||
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
"""Return the direct children of a vault directory (directories first)."""
|
||||
from backend.indexer import SUPPORTED_EXTENSIONS
|
||||
from backend.vault_settings import get_vault_setting
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
target = resolve_safe_path(root, path) if path else root.resolve()
|
||||
|
||||
if not target.exists():
|
||||
raise ServiceError(
|
||||
f"Path not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
|
||||
hide_hidden = (get_vault_setting(vault_name) or {}).get("hideHiddenFiles", False)
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
try:
|
||||
for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||||
if hide_hidden and entry.name.startswith("."):
|
||||
continue
|
||||
rel = str(entry.relative_to(root)).replace("\\", "/")
|
||||
if entry.is_dir():
|
||||
# Count only direct children (files and subdirs) for performance.
|
||||
try:
|
||||
file_count = sum(
|
||||
1 for child in entry.iterdir()
|
||||
if (not hide_hidden or not child.name.startswith("."))
|
||||
and (child.is_file() and (child.suffix.lower() in SUPPORTED_EXTENSIONS or child.name.lower() in ("dockerfile", "makefile"))
|
||||
or child.is_dir())
|
||||
)
|
||||
except PermissionError:
|
||||
file_count = 0
|
||||
items.append({
|
||||
"name": entry.name,
|
||||
"path": rel,
|
||||
"type": "directory",
|
||||
"children_count": file_count,
|
||||
})
|
||||
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
|
||||
items.append({
|
||||
"name": entry.name,
|
||||
"path": rel,
|
||||
"type": "file",
|
||||
"size": entry.stat().st_size,
|
||||
"extension": entry.suffix.lower(),
|
||||
})
|
||||
except PermissionError:
|
||||
raise ServiceError("Permission denied", code="permission_denied", status=403) from None
|
||||
|
||||
return {"vault": vault_name, "path": path, "items": items}
|
||||
+11
-21
@@ -15,6 +15,8 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.auth.middleware import check_vault_access
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path as _resolve_service_path
|
||||
|
||||
logger = logging.getLogger("obsigate.tools")
|
||||
|
||||
@@ -138,32 +140,20 @@ class ToolContext:
|
||||
)
|
||||
|
||||
|
||||
def resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
|
||||
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
||||
"""Resolve a vault-relative path, rejecting traversal outside the vault.
|
||||
|
||||
Mirrors ``backend.main._resolve_safe_path`` but raises a domain error
|
||||
instead of ``HTTPException`` so the tool layer stays transport-agnostic.
|
||||
Delegates to the shared :func:`backend.services.paths.resolve_safe_path`
|
||||
and maps its :class:`ServiceError` to tool domain errors so the tool layer
|
||||
stays transport-agnostic.
|
||||
|
||||
Raises:
|
||||
ToolPermissionError: When the resolved path escapes the vault root.
|
||||
ToolError: When the path cannot be resolved.
|
||||
"""
|
||||
full_path = vault_root / (relative_path or "")
|
||||
try:
|
||||
resolved = full_path.resolve(strict=False)
|
||||
root = vault_root.resolve(strict=False)
|
||||
except Exception as e:
|
||||
raise ToolError(f"Path resolution error: {e}", code="path_error") from e
|
||||
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
except ValueError:
|
||||
# Case-insensitive fallback for Windows / Docker path casing.
|
||||
if not str(resolved).lower().startswith(str(root).lower()):
|
||||
logger.warning(f"Path outside vault: vault={root}, requested={relative_path}")
|
||||
raise ToolPermissionError(
|
||||
"Access denied: path outside vault",
|
||||
code="path_outside_vault",
|
||||
details={"path": relative_path},
|
||||
) from None
|
||||
return resolved
|
||||
return _resolve_service_path(vault_root, relative_path)
|
||||
except ServiceError as e:
|
||||
if e.code in ("path_outside_vault", "permission_denied"):
|
||||
raise ToolPermissionError(e.message, code=e.code, details=e.details) from e
|
||||
raise ToolError(e.message, code=e.code, details=e.details) from e
|
||||
|
||||
@@ -18,6 +18,7 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.tools.audit import log_tool_call
|
||||
from backend.tools.context import (
|
||||
ToolConfirmationRequired,
|
||||
@@ -136,6 +137,17 @@ def _audit(ctx: ToolContext, spec: ToolSpec, arguments: dict[str, Any], *, ok: b
|
||||
logger.debug(f"Tool audit failed for '{spec.name}': {e}")
|
||||
|
||||
|
||||
def _map_service_error(e: ServiceError) -> ToolError:
|
||||
"""Map a shared-layer :class:`ServiceError` to a tool domain error."""
|
||||
if e.code == "not_found":
|
||||
return ToolNotFoundError(e.message, details=e.details)
|
||||
if e.code in ("permission_denied", "path_outside_vault", "vault_access_denied"):
|
||||
return ToolPermissionError(e.message, code=e.code, details=e.details)
|
||||
if e.code == "invalid_arguments":
|
||||
return ToolValidationError(e.message, details=e.details)
|
||||
return ToolError(e.message, code=e.code, details=e.details)
|
||||
|
||||
|
||||
def call_tool(
|
||||
name: str,
|
||||
ctx: ToolContext,
|
||||
@@ -191,6 +203,10 @@ def call_tool(
|
||||
except ToolError as e:
|
||||
_audit(ctx, spec, arguments, ok=False, error=e.code)
|
||||
raise
|
||||
except ServiceError as e:
|
||||
mapped = _map_service_error(e)
|
||||
_audit(ctx, spec, arguments, ok=False, error=mapped.code)
|
||||
raise mapped from e
|
||||
except Exception as e:
|
||||
logger.error(f"Tool '{name}' failed: {e}")
|
||||
exec_error = ToolError(f"Tool '{name}' failed: {e}", code="tool_execution_error")
|
||||
|
||||
+26
-79
@@ -1,24 +1,21 @@
|
||||
"""Built-in tool services (Phase 0 — read/search).
|
||||
|
||||
These functions are the single source of truth consumed by both the in-app
|
||||
assistant and the MCP server. They delegate to the existing core modules
|
||||
(``backend.indexer``, ``backend.search``) rather than duplicating route logic.
|
||||
assistant and the MCP server. They delegate to the shared business-logic
|
||||
services (``backend.services``) so routes and tools never diverge.
|
||||
Mutating tools (create/edit/rename/move/delete) are added in later phases.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.tools.context import (
|
||||
ToolContext,
|
||||
ToolError,
|
||||
ToolNotFoundError,
|
||||
ToolRisk,
|
||||
resolve_safe_path,
|
||||
)
|
||||
from backend.services.files import read_file_text
|
||||
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.tools.context import ToolContext, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import (
|
||||
ListDirectoryInput,
|
||||
@@ -34,15 +31,6 @@ logger = logging.getLogger("obsigate.tools.service")
|
||||
TOOL_MAX_READ_BYTES = 200_000
|
||||
|
||||
|
||||
def _vault_data_or_raise(vault: str) -> dict[str, Any]:
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
data = get_vault_data(vault)
|
||||
if not data:
|
||||
raise ToolNotFoundError(f"Vault '{vault}' not found", details={"vault": vault})
|
||||
return data
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_vaults",
|
||||
description="List the vaults the current user is allowed to access.",
|
||||
@@ -51,15 +39,10 @@ def _vault_data_or_raise(vault: str) -> dict[str, Any]:
|
||||
)
|
||||
def list_vaults(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, Any]]:
|
||||
"""Return accessible vaults with a file count."""
|
||||
from backend.indexer import get_vault_data, get_vault_names
|
||||
|
||||
vaults: list[dict[str, Any]] = []
|
||||
for name in get_vault_names():
|
||||
if not ctx.has_vault_access(name):
|
||||
continue
|
||||
data = get_vault_data(name) or {}
|
||||
vaults.append({"name": name, "file_count": len(data.get("files", []))})
|
||||
return vaults
|
||||
return [
|
||||
{"name": v["name"], "file_count": v["file_count"]}
|
||||
for v in list_accessible_vaults(ctx.user)
|
||||
]
|
||||
|
||||
|
||||
@tool(
|
||||
@@ -71,28 +54,11 @@ def list_vaults(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, An
|
||||
)
|
||||
def list_directory(ctx: ToolContext, params: ListDirectoryInput) -> list[dict[str, Any]]:
|
||||
"""Return the entries of a vault directory (direct children only)."""
|
||||
data = _vault_data_or_raise(params.vault)
|
||||
root = Path(data["path"])
|
||||
target = resolve_safe_path(root, params.path) if params.path else root.resolve()
|
||||
|
||||
if not target.exists() or not target.is_dir():
|
||||
raise ToolNotFoundError(f"Directory not found: {params.path}", details={"vault": params.vault, "path": params.path})
|
||||
|
||||
from backend.vault_settings import get_vault_setting
|
||||
|
||||
hide_hidden = (get_vault_setting(params.vault) or {}).get("hideHiddenFiles", False)
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||||
if hide_hidden and entry.name.startswith("."):
|
||||
continue
|
||||
rel = str(entry.relative_to(root)).replace("\\", "/")
|
||||
items.append({
|
||||
"name": entry.name,
|
||||
"path": rel,
|
||||
"type": "directory" if entry.is_dir() else "file",
|
||||
})
|
||||
return items
|
||||
data = browse_directory(params.vault, params.path)
|
||||
return [
|
||||
{"name": item["name"], "path": item["path"], "type": item["type"]}
|
||||
for item in data["items"]
|
||||
]
|
||||
|
||||
|
||||
@tool(
|
||||
@@ -104,27 +70,12 @@ def list_directory(ctx: ToolContext, params: ListDirectoryInput) -> list[dict[st
|
||||
)
|
||||
def read_file(ctx: ToolContext, params: ReadFileInput) -> dict[str, Any]:
|
||||
"""Return the (redacted) content of a vault file."""
|
||||
data = _vault_data_or_raise(params.vault)
|
||||
root = Path(data["path"])
|
||||
target = resolve_safe_path(root, params.path)
|
||||
|
||||
if not target.exists() or not target.is_file():
|
||||
raise ToolNotFoundError(f"File not found: {params.path}", details={"vault": params.vault, "path": params.path})
|
||||
|
||||
size = target.stat().st_size
|
||||
if size > TOOL_MAX_READ_BYTES:
|
||||
raise ToolError(
|
||||
f"File too large ({size} bytes > {TOOL_MAX_READ_BYTES})",
|
||||
code="file_too_large",
|
||||
details={"vault": params.vault, "path": params.path, "size": size},
|
||||
)
|
||||
|
||||
content = target.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
from backend.secret_redactor import redact_file_content
|
||||
|
||||
content = redact_file_content(content, params.path)
|
||||
return {"vault": params.vault, "path": params.path, "size": size, "content": content}
|
||||
return read_file_text(
|
||||
params.vault,
|
||||
params.path,
|
||||
redact=True,
|
||||
max_bytes=TOOL_MAX_READ_BYTES,
|
||||
)
|
||||
|
||||
|
||||
@tool(
|
||||
@@ -135,10 +86,8 @@ def read_file(ctx: ToolContext, params: ReadFileInput) -> dict[str, Any]:
|
||||
)
|
||||
def search_fulltext(ctx: ToolContext, params: SearchFulltextInput) -> list[dict[str, Any]]:
|
||||
"""Return ranked search results, filtered to accessible vaults."""
|
||||
from backend.search import search
|
||||
|
||||
results = search(params.q, vault_filter=params.vault, tag_filter=params.tag, limit=params.limit)
|
||||
return [r for r in results if ctx.has_vault_access(r["vault"])]
|
||||
payload = search_vaults(params.q, vault=params.vault, tag=params.tag, limit=params.limit)
|
||||
return [r for r in payload["results"] if ctx.has_vault_access(r["vault"])]
|
||||
|
||||
|
||||
@tool(
|
||||
@@ -149,11 +98,9 @@ def search_fulltext(ctx: ToolContext, params: SearchFulltextInput) -> list[dict[
|
||||
)
|
||||
def list_tags(ctx: ToolContext, params: ListTagsInput) -> list[dict[str, Any]]:
|
||||
"""Return tags sorted by descending count."""
|
||||
from backend.search import get_all_tags
|
||||
|
||||
if params.vault and params.vault != "all":
|
||||
ctx.require_vault_access(params.vault)
|
||||
return [{"tag": tag, "count": count} for tag, count in get_all_tags(params.vault).items()]
|
||||
return [{"tag": tag, "count": count} for tag, count in _list_tags(params.vault).items()]
|
||||
|
||||
from backend.indexer import get_vault_names
|
||||
|
||||
@@ -161,6 +108,6 @@ def list_tags(ctx: ToolContext, params: ListTagsInput) -> list[dict[str, Any]]:
|
||||
for name in get_vault_names():
|
||||
if not ctx.has_vault_access(name):
|
||||
continue
|
||||
for tag, count in get_all_tags(name).items():
|
||||
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])]
|
||||
|
||||
Reference in New Issue
Block a user