feat(ai): commandes @/ & skills, analyse d'images et capacites des modeles (#81)
CI / lint (push) Successful in 1m10s
CI / security (push) Successful in 43s
CI / test (push) Successful in 2m34s
CI / build (push) Successful in 43s
CI / e2e (push) Successful in 10m59s

This commit is contained in:
2026-09-12 11:38:46 -04:00
parent 88bb817a62
commit f049e208b6
25 changed files with 2581 additions and 31 deletions
+146 -18
View File
@@ -15,12 +15,17 @@ 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")
@@ -40,6 +45,14 @@ class BooksLMContextRequest(BaseModel):
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)",
)
class BooksLMChatRequest(BaseModel):
@@ -50,7 +63,24 @@ class BooksLMChatRequest(BaseModel):
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}]",
@@ -93,15 +123,35 @@ def _resolve_vault_path(vault: str | None, current_user):
return vault, Path(vault_data["path"])
def _build_context(mode: str, vault_path: Path | None, directory: str, context_files: list[str]):
"""Collect the context payload for the requested mode."""
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":
return empty_context("general")
if mode == "documents":
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.
return ctx if ctx["file_count"] else empty_context("general")
return collect_directory_context(vault_path, directory) # type: ignore[arg-type]
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 _resolve_system_prompt(req, current_user) -> str:
@@ -114,16 +164,85 @@ def _resolve_system_prompt(req, current_user) -> str:
if mode != "general":
_, vault_path = _resolve_vault_path(req.vault, current_user)
context = _build_context(mode, vault_path, req.directory, req.context_files)
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":
return build_general_system_prompt(list(index.keys()))
if effective_mode == "documents":
return build_system_prompt(context, scope="documents")
if context["file_count"] == 0:
prompt = build_general_system_prompt(list(index.keys()))
elif effective_mode == "documents":
prompt = build_system_prompt(context, scope="documents")
elif context["file_count"] == 0:
raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier")
return build_system_prompt(context, scope="directory")
else:
prompt = build_system_prompt(context, scope="directory")
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:
@@ -157,10 +276,15 @@ async def api_bookslm_context(
"""
mode = _normalize_mode(req.mode)
if mode == "general":
return empty_context("general")
_, vault_path = _resolve_vault_path(req.vault, current_user)
return _build_context(mode, vault_path, req.directory, req.context_files)
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(
@@ -178,7 +302,9 @@ async def api_bookslm_chat(
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:
@@ -186,7 +312,7 @@ async def api_bookslm_chat(
content = turn.get("content", "")
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
messages.append({"role": "user", "content": req.message})
messages.append({"role": "user", "content": _build_user_content(req, vault_path)})
async def generate_sse():
try:
@@ -246,7 +372,9 @@ async def api_bookslm_agent(
and the conversation snapshot; the client resumes by echoing them back in
``confirm`` / ``confirm_messages``.
"""
_validate_vision_support(req)
system_prompt = _resolve_system_prompt(req, current_user)
vault_path = _resolve_optional_vault_path(req, current_user)
messages: list[dict] = [{"role": "system", "content": system_prompt}]
for turn in req.conversation_history:
@@ -254,7 +382,7 @@ async def api_bookslm_agent(
content = turn.get("content", "")
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
messages.append({"role": "user", "content": req.message})
messages.append({"role": "user", "content": _build_user_content(req, vault_path)})
ctx = ToolContext(user=current_user, mode=ToolMode.IN_APP)