Files
ObsiGate/backend/bookslm_routes.py
T
bruno e1842043d8
CI / lint (push) Successful in 2m1s
CI / security (push) Successful in 1m25s
CI / test (push) Successful in 4m19s
CI / build (push) Successful in 1m26s
CI / e2e (push) Successful in 13m38s
feat: assistant IA — approbation groupee des actions, bloc d'etapes, refresh UI et bouton Stop (BUG-074, BUG-075, BUG-076, BUG-077)
2026-09-24 10:05:32 -04:00

718 lines
28 KiB
Python

"""BooksLM API routes — directory-scoped AI chat for Obsidian vaults."""
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, stream_completion
from backend.ai_history import delete_session, get_session, list_sessions, upsert_session
from backend.auth.middleware import check_vault_access, require_auth
from backend.bookslm import (
build_general_system_prompt,
build_system_prompt,
collect_adhoc_context,
collect_directory_context,
collect_files_context,
empty_context,
load_vault_image_data_url,
merge_contexts,
)
from backend.indexer import get_vault_data, index
from backend.model_capabilities import model_supports_vision
from backend.schemas import BooksLMContextResponse
from backend.skills import get_skill_prompt
from backend.tools.api import ToolContext, ToolMode
logger = logging.getLogger("obsigate.bookslm_routes")
router = APIRouter(prefix="/api/ai/bookslm", tags=["BooksLM"])
VALID_MODES = {"directory", "documents", "general"}
# ── Request models ──
class BooksLMContextRequest(BaseModel):
vault: str | None = Field(default=None, description="Vault name (required for directory/documents modes)")
directory: str = Field(default="", description="Relative directory path within the vault")
mode: str = Field(default="directory", description="Context mode: 'directory', 'documents' or 'general'")
context_files: list[str] = Field(
default_factory=list,
description="Relative file paths to use as context (documents mode)",
)
extra_files: list[str] = Field(
default_factory=list,
description="Ad-hoc files added with the '@' command (any mode)",
)
extra_directories: list[str] = Field(
default_factory=list,
description="Ad-hoc directories added with the '@' command (any mode)",
)
app_context: dict[str, Any] | None = Field(
default=None,
description="Live client UI state for the General assistant: open_documents, "
"current_path, directory, vault, search_query, search_total, search_results.",
)
class BooksLMChatRequest(BaseModel):
vault: str | None = Field(default=None, description="Vault name (required for directory/documents modes)")
directory: str = Field(default="", description="Relative directory path within the vault")
mode: str = Field(default="directory", description="Context mode: 'directory', 'documents' or 'general'")
context_files: list[str] = Field(
default_factory=list,
description="Relative file paths to use as context (documents mode)",
)
extra_files: list[str] = Field(
default_factory=list,
description="Ad-hoc files added with the '@' command (any mode)",
)
extra_directories: list[str] = Field(
default_factory=list,
description="Ad-hoc directories added with the '@' command (any mode)",
)
message: str = Field(description="User message")
images: list[dict[str, Any]] = Field(
default_factory=list,
description="Images for vision models. Each item: {data, mime_type} (pasted) "
"or {path} (vault-relative file).",
)
skill: str | None = Field(
default=None,
description="Skill id selected with the '/' command; its prompt is added to the system prompt.",
)
conversation_history: list[dict[str, str]] = Field(
default_factory=list,
description="Previous conversation turns [{role, content}]",
)
provider: str | None = Field(
default=None,
description="AI provider override (e.g. 'deepseek', 'openrouter', 'gemini', 'nvidia', 'xiaomi', 'mistral', 'qwencloud'). "
"If not set, uses DEFAULT_PROVIDER.",
)
model: str | None = Field(
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.",
)
confirm_all: bool = Field(
default=False,
description="Global approval (BUG-075): apply every pending action of the batch "
"and auto-approve the remaining mutating calls of the same run, "
"so the run does not pause on each action.",
)
app_context: dict[str, Any] | None = Field(
default=None,
description="Live client UI state for the General assistant: open_documents, "
"current_path, directory, vault, search_query, search_total, search_results.",
)
def _normalize_mode(mode: str | None) -> str:
mode = (mode or "directory").lower()
return mode if mode in VALID_MODES else "directory"
def _resolve_vault_path(vault: str | None, current_user):
"""Validate vault access and return (vault_name, vault_path)."""
if not vault:
raise HTTPException(status_code=400, detail="Champ 'vault' requis pour ce contexte")
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
vault_data = get_vault_data(vault)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault}' not found")
return vault, Path(vault_data["path"])
def _build_context(
mode: str,
vault_path: Path | None,
directory: str,
context_files: list[str],
extra_files: list[str] | None = None,
extra_directories: list[str] | None = None,
):
"""Collect the context payload for the requested mode.
Ad-hoc files/directories (``@`` command) are merged on top of the base
context. In General mode, attaching ad-hoc files promotes the effective
scope to ``documents`` so the prompt actually includes their content.
"""
if mode == "general":
base = empty_context("general")
elif mode == "documents":
ctx = collect_files_context(vault_path, context_files, scope="documents") # type: ignore[arg-type]
# No open document could be read → degrade gracefully to General.
base = ctx if ctx["file_count"] else empty_context("general")
else:
base = collect_directory_context(vault_path, directory) # type: ignore[arg-type]
if (extra_files or extra_directories) and vault_path is not None:
adhoc_scope = "documents" if base.get("scope") == "general" else base.get("scope", "directory")
adhoc = collect_adhoc_context(vault_path, extra_files, extra_directories, scope=adhoc_scope)
if adhoc["file_count"]:
base = merge_contexts(base, adhoc)
return base
def _submitted_app_context(req) -> dict[str, Any] | None:
"""Return the client-submitted live app state (may be None)."""
ctx = getattr(req, "app_context", None)
return ctx if isinstance(ctx, dict) else None
def _recent_files_for_prompt(current_user, limit: int = 10) -> list[dict[str, Any]]:
"""Best-effort list of the user's most recently modified files.
Used to give the General assistant a sense of what the user has been
working on. Never raises: any failure yields an empty list.
"""
try:
from backend.services.recent import list_recent
username = current_user.get("username") if isinstance(current_user, dict) else None
user_vaults = (
current_user.get("_token_vaults") or current_user.get("vaults", [])
if isinstance(current_user, dict)
else []
)
data = list_recent(username, user_vaults, limit=limit, mode="modified")
return list(data.get("files", []))
except Exception: # pragma: no cover - defensive, prompt enrichment only
logger.debug("Could not gather recent files for assistant prompt", exc_info=True)
return []
def _resolve_system_prompt(req, current_user, agent: bool = False) -> str:
"""Resolve the vault access and build the assistant system prompt.
Shared by the classic chat endpoint and the tool-calling agent endpoint.
``agent=True`` selects the native-tool action protocol (no text
``obsigate-action`` blocks) for the General/empty-directory prompts.
"""
mode = _normalize_mode(req.mode)
vault_path: Path | None = None
if mode != "general":
_, vault_path = _resolve_vault_path(req.vault, current_user)
elif getattr(req, "extra_files", None) or getattr(req, "extra_directories", None):
# General mode has no base context, but ad-hoc files/directories added
# with `@` still need a vault to be read from.
vault_path = _resolve_optional_vault_path(req, current_user)
context = _build_context(
mode,
vault_path,
req.directory,
req.context_files,
getattr(req, "extra_files", None),
getattr(req, "extra_directories", None),
)
effective_mode = context.get("scope", mode)
if effective_mode == "general":
prompt = build_general_system_prompt(
list(index.keys()),
app_context=_submitted_app_context(req),
recent_files=_recent_files_for_prompt(current_user),
agent=agent,
)
elif effective_mode == "documents":
prompt = build_system_prompt(context, scope="documents", vault_name=req.vault)
elif context["file_count"] == 0:
# Empty (or unreadable) directory: don't block the request. Answer as
# the General assistant would, telling the model the folder is empty so
# it can still help (create a file, explain the app, etc.).
prompt = build_general_system_prompt(
list(index.keys()),
app_context=_submitted_app_context(req),
recent_files=_recent_files_for_prompt(current_user),
agent=agent,
)
prompt += (
f"\n## Dossier vide\nLe dossier « {req.directory or '/'} » "
f"(vault {req.vault}) ne contient aucun fichier markdown exploitable. "
"Réponds quand même à la demande de l'utilisateur sans contexte "
"documentaire, et propose une action de création si c'est pertinent.\n"
)
else:
prompt = build_system_prompt(context, scope="directory", vault_name=req.vault)
if agent and effective_mode != "general" and context["file_count"] > 0:
prompt += (
"\n## Mode agent\n"
"Utilise les outils natifs (function calling) pour agir sur les fichiers "
"(`create_file`, `create_directory`, `append_to_file`, `edit_file`, …). "
"N'écris jamais de bloc ```obsigate-action```."
)
skill_id = getattr(req, "skill", None)
if skill_id:
skill_prompt = get_skill_prompt(skill_id, current_user)
if skill_prompt:
prompt += "\n\n## Skill actif\n" + skill_prompt
return prompt
def _resolve_optional_vault_path(req, current_user) -> Path | None:
"""Best-effort vault path resolution (never raises)."""
if not getattr(req, "vault", None):
return None
try:
_, vault_path = _resolve_vault_path(req.vault, current_user)
return vault_path
except HTTPException:
return None
def _build_user_content(req, vault_path: Path | None):
"""Build the user message content, attaching images when present.
Returns a plain string when there is no image, otherwise an OpenAI-style
multimodal content array (which ``ai_chat`` adapts for Gemini).
"""
images = getattr(req, "images", None) or []
if not images:
return req.message
parts: list[dict[str, Any]] = [{"type": "text", "text": req.message}]
for image in images:
if not isinstance(image, dict):
continue
data_url: str | None = None
if image.get("data"):
mime = image.get("mime_type") or "image/png"
data_url = f"data:{mime};base64,{image['data']}"
elif image.get("path") and vault_path is not None:
data_url = load_vault_image_data_url(vault_path, str(image["path"]))
if data_url:
parts.append({"type": "image_url", "image_url": {"url": data_url}})
return parts
def _validate_vision_support(req) -> None:
"""Reject image requests when the selected model cannot analyse images."""
if not (getattr(req, "images", None)):
return
provider = _resolve_provider_name(req.provider)
if not provider:
return
from backend.ai import PROVIDERS
model = req.model or PROVIDERS.get(provider, {}).get("model", "")
if not model_supports_vision(provider, model):
raise HTTPException(
status_code=400,
detail=f"Le modèle '{model or provider}' ne supporte pas l'analyse d'images. "
"Choisissez un modèle compatible vision.",
)
def _resolve_provider_name(requested: str | None) -> str | None:
"""Pick the provider to use: explicit override, else first available."""
from backend.ai import DEFAULT_PROVIDER, PROVIDERS
cfg_name = (requested or DEFAULT_PROVIDER).lower()
if cfg_name in PROVIDERS and PROVIDERS[cfg_name].get("api_key"):
return cfg_name
for pname, pcfg in PROVIDERS.items():
if pcfg.get("api_key") and pname != "gemini":
return pname
return None
def _effective_model(provider: str | None, requested: str | None) -> str:
"""Model actually used for a request.
The client may leave `model` empty (provider default) — reporting the raw
request would show nothing in the "provider · model" tag, so the provider's
configured default is returned instead.
"""
if requested:
return requested
if not provider:
return ""
from backend.ai import PROVIDERS
return PROVIDERS.get(provider, {}).get("model", "") or ""
def _tool_sources(rec) -> list[dict[str, str]]:
"""Compact web sources of a tool result (rendered as links in the UI).
Only the web tools produce sources: ``web_search`` returns ranked results,
``fetch_url`` a single page. Everything else yields an empty list so the
SSE payload stays small.
"""
data = rec.result if isinstance(rec.result, dict) else {}
name = rec.name or ""
sources: list[dict[str, str]] = []
if name == "web_search":
for item in (data.get("results") or [])[:8]:
if not isinstance(item, dict):
continue
url = item.get("url") or ""
if not url:
continue
sources.append({"title": item.get("title") or url, "url": url})
elif name == "fetch_url":
url = data.get("url") or ""
if url:
sources.append({"title": data.get("title") or url, "url": url})
return sources
def _tool_event_sse(rec) -> str:
"""Serialize one executed tool call as an SSE ``tool`` event."""
payload = json.dumps(
{
"name": rec.name,
"ok": rec.ok,
"arguments": rec.arguments,
"step": rec.step,
"sources": _tool_sources(rec),
},
ensure_ascii=False,
)
return f"event: tool\ndata: {payload}\n\n"
def _thought_event_sse(note: dict) -> str:
"""Serialize one intermediate reasoning note as an SSE ``step`` event."""
payload = json.dumps({"step": note}, ensure_ascii=False)
return f"event: step\ndata: {payload}\n\n"
# ── Endpoints ──
@router.post("/context", response_model=BooksLMContextResponse)
async def api_bookslm_context(
req: BooksLMContextRequest,
current_user=Depends(require_auth),
):
"""Collect context for the AI assistant.
Three modes are supported:
* ``directory`` — every supported file under a vault directory;
* ``documents`` — only the explicitly listed open documents;
* ``general`` — no document context (assistant for the app itself).
"""
mode = _normalize_mode(req.mode)
if mode == "general":
if not (req.extra_files or req.extra_directories):
return empty_context("general")
vault_path = _resolve_optional_vault_path(req, current_user)
else:
_, vault_path = _resolve_vault_path(req.vault, current_user)
return _build_context(
mode, vault_path, req.directory, req.context_files,
req.extra_files, req.extra_directories,
)
@router.post(
"/chat",
response_class=StreamingResponse,
responses={200: {"content": {"text/event-stream": {}}, "description": "SSE token stream"}},
)
async def api_bookslm_chat(
req: BooksLMChatRequest,
current_user=Depends(require_auth),
):
"""Chat with the AI assistant.
Builds a system prompt from the selected context (directory, open
documents or general app knowledge), then streams the provider's answer
as Server-Sent Events.
"""
_validate_vision_support(req)
system_prompt = _resolve_system_prompt(req, current_user)
vault_path = _resolve_optional_vault_path(req, current_user)
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": _build_user_content(req, vault_path)})
async def generate_sse():
try:
# 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
# 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": _effective_model(cfg_name, req.model),
},
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}")
error_data = json.dumps({"error": str(e)}, ensure_ascii=False)
yield f"event: error\ndata: {error_data}\n\n"
return StreamingResponse(
generate_sse(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post(
"/agent",
response_class=StreamingResponse,
responses={200: {"content": {"text/event-stream": {}}, "description": "SSE tool/agent stream"}},
)
async def api_bookslm_agent(
req: BooksLMChatRequest,
current_user=Depends(require_auth),
):
"""Chat with the tool-calling 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) carrying the pending
``actions`` (every mutating call of the turn) and the conversation snapshot;
the client resumes by echoing them back in ``confirm`` / ``confirm_messages``,
optionally with ``confirm_all`` to apply the whole batch and auto-approve the
rest of the run (BUG-075).
"""
_validate_vision_support(req)
system_prompt = _resolve_system_prompt(req, current_user, agent=True)
vault_path = _resolve_optional_vault_path(req, current_user)
messages: list[dict] = [{"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": _build_user_content(req, vault_path)})
ctx = ToolContext(user=current_user, mode=ToolMode.IN_APP)
if req.confirm_all:
# BUG-075: a single global approval authorizes the whole plan, so the
# run no longer pauses on every subsequent mutating call.
ctx.confirmed = True
async def _llm(msgs, tool_schemas):
return await chat_completion(
msgs,
tools=tool_schemas,
provider=req.provider,
model=req.model,
temperature=0.3,
# Tool-call arguments can carry a whole file body (e.g. a generated
# table): leave more room than the plain-chat default.
max_tokens=8192,
)
async def generate_sse():
import asyncio
try:
cfg_name = _resolve_provider_name(req.provider)
if cfg_name is None:
error_data = json.dumps(
{"error": "Aucun fournisseur AI configuré (clés API manquantes)"},
ensure_ascii=False,
)
yield f"event: error\ndata: {error_data}\n\n"
return
# Stream tool events live: each executed step is pushed on the
# queue by the loop callback and emitted as soon as it happens,
# so the UI can grow its « N steps » block while thinking.
queue: asyncio.Queue = asyncio.Queue()
def _on_tool(rec) -> None:
queue.put_nowait(("tool", rec))
run_task = asyncio.create_task(run_agent(
messages,
ctx=ctx,
llm=_llm,
resume_messages=req.confirm_messages,
confirm_pending=req.confirm,
on_tool_call=_on_tool,
on_thought=lambda note: queue.put_nowait(("thought", note)),
))
# Drain every completed step as soon as it lands, while the agent
# keeps running in the background. If the client disconnects, the
# generator is cancelled: release the run so it cannot orphan.
try:
while True:
try:
kind, item = await asyncio.wait_for(queue.get(), timeout=0.25)
except asyncio.TimeoutError:
if run_task.done():
break
continue
yield _tool_event_sse(item) if kind == "tool" else _thought_event_sse(item)
while not queue.empty():
kind, item = queue.get_nowait()
yield _tool_event_sse(item) if kind == "tool" else _thought_event_sse(item)
result = run_task.result()
finally:
if not run_task.done():
run_task.cancel()
if result.stopped == "confirmation_required":
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(
{
"token": result.content,
"provider": cfg_name,
"model": _effective_model(cfg_name, req.model),
"iterations": result.iterations,
"stopped": result.stopped,
},
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 agent error: {e}")
error_data = json.dumps({"error": str(e)}, ensure_ascii=False)
yield f"event: error\ndata: {error_data}\n\n"
return StreamingResponse(
generate_sse(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# ── Persistent conversation history (#95) ─────────────────────────────
class BookslmSession(BaseModel):
"""Full assistant conversation persisted server-side (#95).
The shape mirrors the client session object so round-tripping is lossless
(transient fields such as ``confirmation``/``payload`` are stripped
client-side before upload).
"""
id: str = Field(description="Stable session id (s-<ts36>-<rand>)")
title: str = Field(default="", description="Derived human title")
mode: str = Field(default="general", description="'directory', 'documents' or 'general'")
vault: str | None = Field(default=None, description="Vault name for the context")
directory: str = Field(default="", description="Relative directory path")
documents: list[dict[str, str]] = Field(
default_factory=list,
description="Open documents for the 'documents' mode",
)
context: str = Field(default="", description="Client context key of the assistant")
createdAt: int | None = Field(default=None, description="Creation ISO ms timestamp")
updatedAt: int | None = Field(default=None, description="Last update ISO ms timestamp")
messages: list[dict[str, Any]] = Field(
default_factory=list,
description="Conversation turns [{role, content}]",
)
def _session_user(current_user) -> str:
return current_user.get("username") if isinstance(current_user, dict) else "" # type: ignore[return-value]
@router.get("/history", response_model=dict[str, list[dict[str, Any]]])
async def api_bookslm_history_list(current_user=Depends(require_auth)):
"""List the user's assistant conversations (summaries, most recent first).
Messages are not included to keep the list light; fetch the full
conversation with ``GET /history/{id}`` when a session is opened.
"""
return {"sessions": list_sessions(_session_user(current_user))}
@router.get("/history/{session_id}", response_model=dict[str, Any] | None)
async def api_bookslm_history_get(
session_id: str,
current_user=Depends(require_auth),
):
"""Return a single full conversation, or 404 when unknown."""
session = get_session(_session_user(current_user), session_id)
if session is None:
raise HTTPException(status_code=404, detail="Conversation not found")
return session
@router.put("/history/{session_id}", response_model=dict[str, Any])
async def api_bookslm_history_upsert(
session_id: str,
session: BookslmSession,
current_user=Depends(require_auth),
):
"""Create or update a conversation for the current user.
The body id is forced to the path id so a client never writes under a
different key by mistake.
"""
payload = session.model_dump()
payload["id"] = session_id
stored = upsert_session(_session_user(current_user), payload)
if stored is None:
raise HTTPException(status_code=400, detail="Invalid session id")
return stored
@router.delete("/history/{session_id}", response_model=dict[str, bool])
async def api_bookslm_history_delete(
session_id: str,
current_user=Depends(require_auth),
):
"""Delete a conversation for the current user."""
return {"ok": delete_session(_session_user(current_user), session_id)}