feat(ai): commandes @/ & skills, analyse d'images et capacites des modeles (#81)
This commit is contained in:
+146
-18
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user