Files
ObsiGate/backend/bookslm_routes.py
T
bruno 62023acd4d
CI / lint (push) Successful in 55s
CI / security (push) Successful in 37s
CI / test (push) Successful in 1m19s
CI / build (push) Successful in 34s
CI / e2e (push) Successful in 10m27s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
feat(api,ai): #72 OpenAPI 3.1 enrichie + fiabilisation de l'outil AI UI
API (#72):
- backend/openapi_docs.py: 18 tags documentes, assignation auto par prefixe,
  securite bearerAuth/cookieAuth, erreurs 401/403/404/422/500, exemples,
  page /api autonome (liens /docs, /redoc, /openapi.json)
- backend/schemas.py: response_model Pydantic pour ~35 endpoints sans modele
- main.py: FastAPI enrichi + override app.openapi; routes /api et /api/
- ai_routes/bookslm_routes: response_model + doc SSE
- frontend: entree 'API' du menu (i18n FR/EN)
- tests/test_openapi.py (58 tests)

AI UI:
- BooksLM: requetes authentifiees (AuthManager), parsing SSE {token}/error,
  message utilisateur correct (plus le placeholder vide), barre de contexte
- AI Editor: Ctrl/Cmd+J lie une seule fois, libelles i18n, modale de
  reecriture accessible a la place de window.prompt()
- tests/frontend/ai.test.mjs (7 tests) + CI
2026-09-11 01:40:21 -04:00

178 lines
6.8 KiB
Python

"""BooksLM API routes — directory-scoped AI chat for Obsidian vaults."""
import json
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from backend.auth.middleware import check_vault_access, require_auth
from backend.bookslm import build_system_prompt, collect_directory_context
from backend.indexer import get_vault_data
from backend.schemas import BooksLMContextResponse
logger = logging.getLogger("obsigate.bookslm_routes")
router = APIRouter(prefix="/api/ai/bookslm", tags=["BooksLM"])
# ── Request models ──
class BooksLMContextRequest(BaseModel):
vault: str = Field(description="Vault name")
directory: str = Field(default="", description="Relative directory path within the vault")
class BooksLMChatRequest(BaseModel):
vault: str = Field(description="Vault name")
directory: str = Field(default="", description="Relative directory path within the vault")
message: str = Field(description="User message")
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.",
)
# ── Endpoints ──
@router.post("/context", response_model=BooksLMContextResponse)
async def api_bookslm_context(
req: BooksLMContextRequest,
current_user=Depends(require_auth),
):
"""Collect directory context for BooksLM.
Returns file list, content, and metadata for the specified directory.
"""
if not check_vault_access(req.vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{req.vault}'")
vault_data = get_vault_data(req.vault)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{req.vault}' not found")
vault_path = Path(vault_data["path"])
context = collect_directory_context(vault_path, req.directory)
return context
@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 AI about directory contents (BooksLM).
Builds context from the directory, then sends the user message
with a system prompt containing all file contents to the AI provider.
Returns an SSE stream with the response.
"""
if not check_vault_access(req.vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{req.vault}'")
vault_data = get_vault_data(req.vault)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{req.vault}' not found")
vault_path = Path(vault_data["path"])
# Collect context
context = collect_directory_context(vault_path, req.directory)
if context["file_count"] == 0:
raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier")
# Build system prompt
system_prompt = build_system_prompt(context)
# 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}"
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
# 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"
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",
},
)