feat(ai): phase B function calling in-app (agent loop + endpoint /agent)
CI / lint (push) Successful in 57s
CI / security (push) Successful in 39s
CI / test (push) Successful in 1m13s
CI / build (push) Successful in 36s
CI / e2e (push) Successful in 10m13s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 57s
CI / security (push) Successful in 39s
CI / test (push) Successful in 1m13s
CI / build (push) Successful in 36s
CI / e2e (push) Successful in 10m13s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
- backend/ai_chat.py: chat_completion provider-agnostique (OpenAI-compat tools/tool_calls + Gemini functionDeclarations/functionCall), retry sans tools si rejete - backend/agent/loop.py: run_agent multi-etapes (limite 10, truncation, confirmation two-step), LLM injectable - endpoint opt-in POST /api/ai/bookslm/agent (events SSE tool/message/confirmation), extraction _resolve_system_prompt - tests: test_ai_chat.py, test_agent_loop.py + 3 tests endpoint (728 passed au total) - ROADMAP B1/B2/B3/B7 livres ; B4/B5/B6 restants
This commit is contained in:
+130
-20
@@ -8,6 +8,8 @@ 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.auth.middleware import check_vault_access, require_auth
|
||||
from backend.bookslm import (
|
||||
build_general_system_prompt,
|
||||
@@ -18,6 +20,7 @@ from backend.bookslm import (
|
||||
)
|
||||
from backend.indexer import get_vault_data, index
|
||||
from backend.schemas import BooksLMContextResponse
|
||||
from backend.tools.api import ToolContext, ToolMode
|
||||
|
||||
logger = logging.getLogger("obsigate.bookslm_routes")
|
||||
router = APIRouter(prefix="/api/ai/bookslm", tags=["BooksLM"])
|
||||
@@ -90,6 +93,42 @@ def _build_context(mode: str, vault_path: Path | None, directory: str, context_f
|
||||
return collect_directory_context(vault_path, directory) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _resolve_system_prompt(req, current_user) -> str:
|
||||
"""Resolve the vault access and build the assistant system prompt.
|
||||
|
||||
Shared by the classic chat endpoint and the tool-calling agent endpoint.
|
||||
"""
|
||||
mode = _normalize_mode(req.mode)
|
||||
vault_path: Path | None = None
|
||||
if mode != "general":
|
||||
_resolve_vault_path(req.vault, current_user)
|
||||
vault_path = Path(get_vault_data(req.vault)["path"]) # type: ignore[index]
|
||||
|
||||
context = _build_context(mode, vault_path, req.directory, req.context_files)
|
||||
effective_mode = context.get("scope", mode)
|
||||
|
||||
if effective_mode == "general":
|
||||
return build_general_system_prompt(list(index.keys()))
|
||||
if effective_mode == "documents":
|
||||
return build_system_prompt(context, scope="documents")
|
||||
if context["file_count"] == 0:
|
||||
raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier")
|
||||
return build_system_prompt(context, scope="directory")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ── Endpoints ──
|
||||
|
||||
|
||||
@@ -130,26 +169,7 @@ async def api_bookslm_chat(
|
||||
documents or general app knowledge), then streams the provider's answer
|
||||
as Server-Sent Events.
|
||||
"""
|
||||
mode = _normalize_mode(req.mode)
|
||||
|
||||
vault_path: Path | None = None
|
||||
if mode != "general":
|
||||
_resolve_vault_path(req.vault, current_user)
|
||||
vault_path = Path(get_vault_data(req.vault)["path"]) # type: ignore[index]
|
||||
|
||||
# Collect context
|
||||
context = _build_context(mode, vault_path, req.directory, req.context_files)
|
||||
effective_mode = context.get("scope", mode)
|
||||
|
||||
# Build system prompt
|
||||
if effective_mode == "general":
|
||||
system_prompt = build_general_system_prompt(list(index.keys()))
|
||||
elif effective_mode == "documents":
|
||||
system_prompt = build_system_prompt(context, scope="documents")
|
||||
else:
|
||||
if context["file_count"] == 0:
|
||||
raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier")
|
||||
system_prompt = build_system_prompt(context, scope="directory")
|
||||
system_prompt = _resolve_system_prompt(req, current_user)
|
||||
|
||||
# Call AI provider
|
||||
from backend.ai import DEFAULT_PROVIDER, PROVIDERS, _call_deepseek_openrouter, _call_gemini
|
||||
@@ -226,3 +246,93 @@ async def api_bookslm_chat(
|
||||
"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).
|
||||
"""
|
||||
system_prompt = _resolve_system_prompt(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": req.message})
|
||||
|
||||
ctx = ToolContext(user=current_user, mode=ToolMode.IN_APP)
|
||||
|
||||
async def _llm(msgs, tool_schemas):
|
||||
return await chat_completion(
|
||||
msgs,
|
||||
tools=tool_schemas,
|
||||
provider=req.provider,
|
||||
model=req.model,
|
||||
temperature=0.3,
|
||||
max_tokens=4096,
|
||||
)
|
||||
|
||||
async def generate_sse():
|
||||
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
|
||||
|
||||
result = await run_agent(messages, ctx=ctx, llm=_llm)
|
||||
|
||||
for rec in result.tool_calls:
|
||||
payload = json.dumps(
|
||||
{"name": rec.name, "ok": rec.ok, "arguments": rec.arguments},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
yield f"event: tool\ndata: {payload}\n\n"
|
||||
|
||||
if result.stopped == "confirmation_required":
|
||||
pending = json.dumps(result.pending or {}, ensure_ascii=False)
|
||||
yield f"event: confirmation\ndata: {pending}\n\n"
|
||||
else:
|
||||
data = json.dumps(
|
||||
{
|
||||
"token": result.content,
|
||||
"provider": cfg_name,
|
||||
"model": req.model or "",
|
||||
"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",
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user