"""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}]", ) # ── 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: cfg_name = DEFAULT_PROVIDER # Check if default provider is available if cfg_name != "gemini" and cfg_name in PROVIDERS and not PROVIDERS[cfg_name].get("api_key"): # Find first available provider for pname, pcfg in PROVIDERS.items(): if pcfg.get("api_key") and pname != "gemini": cfg_name = pname break if cfg_name == "gemini" and PROVIDERS.get("gemini", {}).get("api_key"): 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 if cfg_name in PROVIDERS else None, temperature=0.3, max_tokens=4096, ) # Send the full response as a single SSE event data = json.dumps({"token": response}, 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", }, )