"""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 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") 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") 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", }, )