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
+44 -2
View File
@@ -17,6 +17,7 @@ from __future__ import annotations
import json
import logging
import re
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from typing import Any
@@ -162,6 +163,46 @@ def _gemini_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] |
return [{"functionDeclarations": declarations}]
_DATA_URL_RE = re.compile(r"^data:([^;,]+);base64,(.*)$", re.DOTALL)
def _content_to_gemini_parts(content: Any) -> list[dict[str, Any]]:
"""Convert OpenAI-style message content to Gemini ``parts``.
Accepts either a plain string or a multimodal content array
(``[{"type": "text", ...}, {"type": "image_url", ...}]``). Data URLs are
turned into ``inlineData`` parts so images can be sent to vision models.
"""
if content is None:
return [{"text": ""}]
if isinstance(content, str):
return [{"text": content}]
parts: list[dict[str, Any]] = []
if isinstance(content, list):
for item in content:
if isinstance(item, str):
parts.append({"text": item})
continue
if not isinstance(item, dict):
continue
item_type = item.get("type")
if item_type == "text":
parts.append({"text": item.get("text", "")})
elif item_type == "image_url":
url = (item.get("image_url") or {}).get("url", "")
match = _DATA_URL_RE.match(url or "")
if match:
parts.append({
"inlineData": {"mimeType": match.group(1), "data": match.group(2)},
})
elif url:
parts.append({"fileData": {"fileUri": url}})
if not parts:
parts.append({"text": ""})
return parts
def _gemini_contents(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
"""Split OpenAI-style messages into Gemini ``system`` text + ``contents``."""
system_parts: list[str] = []
@@ -169,7 +210,8 @@ def _gemini_contents(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
for msg in messages:
role = msg.get("role")
if role == "system":
system_parts.append(msg.get("content") or "")
content = msg.get("content") or ""
system_parts.append(content if isinstance(content, str) else "")
elif role == "tool":
contents.append({
"role": "user",
@@ -183,7 +225,7 @@ def _gemini_contents(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
else:
contents.append({
"role": "user" if role == "user" else "model",
"parts": [{"text": msg.get("content") or ""}],
"parts": _content_to_gemini_parts(msg.get("content")),
})
return "\n".join(p for p in system_parts if p).strip(), contents
+32 -1
View File
@@ -2,7 +2,7 @@
import logging
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from backend.ai import (
@@ -25,6 +25,8 @@ from backend.ai import (
ai_translate,
get_default_provider,
)
from backend.auth.middleware import require_auth
from backend.model_capabilities import get_model_capabilities
from backend.schemas import AIStatusResponse
logger = logging.getLogger("obsigate.ai_routes")
@@ -232,3 +234,32 @@ async def api_inline_complete(req: AIRequest):
async def api_to_canvas(req: AIRequest):
"""Convert to Mermaid diagram or outline."""
return await _handle(ai_convert_to_canvas, req)
class ModelCapabilitiesResponse(BaseModel):
"""Capabilities of a single provider/model pair."""
provider: str
model: str
capabilities: dict[str, bool] = Field(
description="Flags: chat, embeddings, rerank, images, video, "
"audio_speech, audio_transcription, vision",
)
@router.get("/model-capabilities", response_model=ModelCapabilitiesResponse)
async def api_model_capabilities(
provider: str = Query(..., description="Provider identifier"),
model: str = Query("", description="Model identifier (optional)"),
current_user=Depends(require_auth),
):
"""Return the curated capability flags for a provider/model pair.
The table is static (see ``backend.model_capabilities``) so the answer is
available offline and never depends on a provider API call.
"""
return {
"provider": provider,
"model": model,
"capabilities": get_model_capabilities(provider, model),
}
+122
View File
@@ -5,9 +5,11 @@ redaction, builds a system prompt with file contents, and caches results
for repeated queries.
"""
import base64
import hashlib
import json
import logging
import mimetypes
import os
import time
from pathlib import Path
@@ -21,6 +23,10 @@ logger = logging.getLogger("obsigate.bookslm")
BOOKSLM_MAX_FILES = int(os.getenv("BOOKSLM_MAX_FILES", "200"))
BOOKSLM_MAX_TOTAL_CHARS = int(os.getenv("BOOKSLM_MAX_TOTAL_CHARS", "200000"))
BOOKSLM_MAX_FILE_CHARS = int(os.getenv("BOOKSLM_MAX_FILE_CHARS", "30000"))
# Maximum size of an image sent to a vision model (bytes, before base64).
BOOKSLM_MAX_IMAGE_BYTES = int(os.getenv("BOOKSLM_MAX_IMAGE_BYTES", "10000000"))
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".avif"}
# ── Cache ──
_cache: dict[str, dict[str, Any]] = {}
@@ -260,6 +266,122 @@ def collect_files_context(
}
def collect_adhoc_context(
vault_path: Path,
files: list[str] | None = None,
directories: list[str] | None = None,
scope: str = "general",
) -> dict[str, Any]:
"""Collect an ad-hoc mix of explicit files and directories.
Backs the assistant ``@`` command: the user can attach extra files and
directories to the current context without changing the base mode. Paths
outside the vault are ignored by the underlying collectors.
Args:
vault_path: Absolute path to the vault root.
files: Relative file paths to include.
directories: Relative directory paths to include (recursive).
scope: Context label exposed to the UI.
Returns:
Same shape as :func:`collect_directory_context`.
"""
vault_resolved = vault_path.resolve()
collected: list[dict[str, Any]] = []
seen: set[str] = set()
total_chars = 0
def _add(entries: list[dict[str, Any]]) -> None:
nonlocal total_chars
for entry in entries:
if len(collected) >= BOOKSLM_MAX_FILES or total_chars >= BOOKSLM_MAX_TOTAL_CHARS:
return
path = entry.get("path")
if not path or path in seen:
continue
seen.add(path)
collected.append(entry)
total_chars += len(entry.get("content", ""))
if files:
_add(collect_files_context(vault_resolved, files, scope=scope)["files"])
for directory in directories or []:
_add(collect_directory_context(vault_resolved, directory)["files"])
return {
"files": collected,
"total_chars": total_chars,
"file_count": len(collected),
"directory_tree": "",
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
"max_files": BOOKSLM_MAX_FILES,
"scope": scope,
}
def merge_contexts(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]:
"""Merge two context payloads, de-duplicating files by path."""
files: list[dict[str, Any]] = []
seen: set[str] = set()
total_chars = 0
for entry in list(base.get("files", [])) + list(extra.get("files", [])):
path = entry.get("path")
if not path or path in seen:
continue
if len(files) >= BOOKSLM_MAX_FILES or total_chars >= BOOKSLM_MAX_TOTAL_CHARS:
break
seen.add(path)
files.append(entry)
total_chars += len(entry.get("content", ""))
tree = base.get("directory_tree", "") or extra.get("directory_tree", "")
scope = extra.get("scope") or base.get("scope", "general")
return {
"files": files,
"total_chars": total_chars,
"file_count": len(files),
"directory_tree": tree,
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
"max_files": BOOKSLM_MAX_FILES,
"scope": scope,
}
def is_image_path(path: str) -> bool:
"""True when the path has a supported image extension."""
return Path(path or "").suffix.lower() in IMAGE_EXTENSIONS
def load_vault_image_data_url(vault_path: Path, rel_path: str) -> str | None:
"""Read a vault image and return it as a ``data:`` URL for vision models.
Returns ``None`` when the path is outside the vault, missing, too large,
or not a supported image.
"""
if not rel_path or not is_image_path(rel_path):
return None
vault_resolved = vault_path.resolve()
try:
target = (vault_resolved / rel_path).resolve()
target.relative_to(vault_resolved)
except (ValueError, OSError):
return None
if not target.is_file():
return None
try:
if target.stat().st_size > BOOKSLM_MAX_IMAGE_BYTES:
logger.warning("Image too large to send: %s", rel_path)
return None
raw = target.read_bytes()
except OSError as exc:
logger.warning("Cannot read image %s: %s", rel_path, exc)
return None
mime = mimetypes.guess_type(str(target))[0] or "image/png"
encoded = base64.b64encode(raw).decode("ascii")
return f"data:{mime};base64,{encoded}"
def empty_context(scope: str = "general") -> dict[str, Any]:
"""Return an empty context payload (used by the General assistant)."""
return {
+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)
+10 -2
View File
@@ -854,6 +854,7 @@ from backend.share import (
revoke_share,
update_shares_after_rename,
)
from backend.skills_routes import router as skills_router
from backend.webhooks import (
create_webhook,
delete_webhook,
@@ -865,6 +866,7 @@ from backend.webhooks import (
app.include_router(auth_router)
app.include_router(ai_router)
app.include_router(bookslm_router)
app.include_router(skills_router)
# Admin Dashboard endpoints (system stats, audit logs, backups, stream)
try:
@@ -3571,6 +3573,8 @@ async def api_list_ai_models(provider: str = Query(...), current_user=Depends(re
"""
provider = provider.lower()
from backend.model_capabilities import get_capabilities_for_models
all_providers = ("deepseek", "openrouter", "gemini", "nvidia", "qwencloud", "xiaomi", "mistral")
if provider not in all_providers:
return {"models": [], "error": f"Unknown provider: {provider}", "source": "validation"}
@@ -3580,7 +3584,9 @@ async def api_list_ai_models(provider: str = Query(...), current_user=Depends(re
if not key:
# No key configured — return curated fallback list so the UI can
# still show what WOULD be available once a key is set.
return {"models": _FALLBACK_MODELS.get(provider, []), "source": "fallback",
fallback = _FALLBACK_MODELS.get(provider, [])
return {"models": fallback, "source": "fallback",
"capabilities": get_capabilities_for_models(provider, fallback),
"note": "API key not configured — showing default model list"}
# Build URL
@@ -3626,13 +3632,15 @@ async def api_list_ai_models(provider: str = Query(...), current_user=Depends(re
default = PROVIDERS.get(provider, {}).get("model")
if default and default not in models:
models = [default] + models
return {"models": models, "source": "live", "count": len(models)}
return {"models": models, "source": "live", "count": len(models),
"capabilities": get_capabilities_for_models(provider, models)}
# Empty list from API — fall through to fallback
raise ValueError("empty model list from provider API")
except Exception as e:
# Network error, auth error, parsing error — use curated fallback
fallback = _FALLBACK_MODELS.get(provider, [])
return {"models": fallback, "source": "fallback", "error": str(e)[:200],
"capabilities": get_capabilities_for_models(provider, fallback),
"note": "Could not reach provider API — showing default model list"}
+144
View File
@@ -0,0 +1,144 @@
"""Curated model-capability metadata for the AI assistant.
ObsiGate does not query every provider for the modalities a model supports
(not all of them expose that information, and the network call is not always
reliable). Instead a static, curated table maps known model-name patterns to
capability flags, with per-provider defaults. The UI uses this to show, when a
model is selected, which features it supports (Chat, Embeddings, Rerank,
Images, Video, Audio Speech, Audio Transcriptions, Vision).
The table is intentionally conservative: an unknown model falls back to the
provider default (usually ``chat`` only), so we never claim a capability the
model may not have.
"""
from __future__ import annotations
from typing import Any
# Ordered list of capability keys exposed to the UI. Keep in sync with the
# frontend ``AI_CAPABILITY_KEYS`` and the i18n ``ai.cap_*`` labels.
CAPABILITY_KEYS: tuple[str, ...] = (
"chat",
"embeddings",
"rerank",
"images",
"video",
"audio_speech",
"audio_transcription",
"vision",
)
def _caps(**kwargs: bool) -> dict[str, bool]:
"""Build a full capability dict (missing flags default to False)."""
base = {key: False for key in CAPABILITY_KEYS}
base.update(kwargs)
return base
# Provider defaults, used when no model-name pattern matches.
_PROVIDER_DEFAULTS: dict[str, dict[str, bool]] = {
"deepseek": _caps(chat=True),
"openrouter": _caps(chat=True),
"gemini": _caps(chat=True, vision=True, embeddings=True, audio_speech=True),
"ollama": _caps(chat=True, embeddings=True),
"nvidia": _caps(chat=True),
"qwencloud": _caps(chat=True),
"xiaomi": _caps(chat=True),
"mistral": _caps(chat=True, embeddings=True),
}
# Ordered (substrings, capabilities) rules — the first matching rule wins.
# More specific modalities are listed before the broad vision/chat rule.
_MODEL_RULES: list[tuple[tuple[str, ...], dict[str, bool]]] = [
# Rerankers.
(("rerank", "cross-encoder"), _caps(rerank=True)),
# Embedding models (usually not chat-capable).
(
("text-embedding", "embed", "bge-", "e5-", "nomic-embed", "gte-"),
_caps(embeddings=True),
),
# Speech-to-text / transcription.
(
("whisper", "transcrib", "-asr", "asr-", "speech-to-text"),
_caps(audio_transcription=True),
),
# Text-to-speech.
(
("-tts", "text-to-speech", "voiceclone", "voicedesign"),
_caps(audio_speech=True),
),
# Image generation.
(
("dall-e", "stable-diffusion", "flux", "imagen", "image-gen"),
_caps(images=True),
),
# Video generation.
(("veo-", "sora", "video-gen", "-video"), _caps(video=True)),
# Vision-capable chat models (multimodal input).
(
(
"gpt-4o",
"gpt-4.1",
"gpt-5",
"o3",
"o4",
"claude-3",
"claude-4",
"gemini",
"qwen-vl",
"-vl-",
"vl-",
"vision",
"llava",
"pixtral",
"internvl",
"minicpm-v",
"llama-3.2-vision",
"mimo-vl",
),
_caps(chat=True, vision=True),
),
]
def get_model_capabilities(provider: str, model: str) -> dict[str, bool]:
"""Return the capability flags for a ``provider``/``model`` pair.
Args:
provider: Provider identifier (e.g. ``"deepseek"``). Case-insensitive.
model: Model identifier (e.g. ``"deepseek-chat"``). May be empty, in
which case the provider default is returned.
Returns:
A dict with every key of :data:`CAPABILITY_KEYS` and boolean values.
"""
provider = (provider or "").strip().lower()
name = (model or "").strip().lower()
if name:
for needles, caps in _MODEL_RULES:
if any(needle in name for needle in needles):
return dict(caps)
return dict(_PROVIDER_DEFAULTS.get(provider, _caps(chat=True)))
def get_capabilities_for_models(
provider: str, models: list[str]
) -> dict[str, dict[str, bool]]:
"""Return a ``{model: capabilities}`` map for a list of models."""
return {model: get_model_capabilities(provider, model) for model in models}
def model_supports_vision(provider: str, model: str) -> bool:
"""True when the given model can accept image input."""
return bool(get_model_capabilities(provider, model).get("vision"))
def capabilities_payload(provider: str, model: str) -> dict[str, Any]:
"""Serialize capabilities for API responses."""
return {
"provider": provider,
"model": model,
"capabilities": get_model_capabilities(provider, model),
}
+4
View File
@@ -373,6 +373,10 @@ class AIModelsResponse(BaseModel):
count: int | None = Field(default=None, description="Number of live models")
error: str | None = Field(default=None, description="Provider/network error, if any")
note: str | None = Field(default=None, description="Explanatory note when using fallback")
capabilities: dict[str, dict[str, bool]] = Field(
default_factory=dict,
description="Per-model capability flags (chat, embeddings, vision, …)",
)
class DiagnosticsResponse(BaseModel):
+317
View File
@@ -0,0 +1,317 @@
"""AI assistant skills & slash-commands.
A *skill* is a reusable prompt/workflow the user can trigger from the assistant
composer with ``/``. ObsiGate ships a set of built-in skills; users can create
their own with ``/create-new-skill``. Built-in skills are code constants, while
user skills are persisted per-user in ``data/skills.json``.
The same module also exposes the metadata for the *admin* commands
(``/help``, ``/providers``, ``/provider``, ``/model``, ``/keys``). Those are
executed client-side (they only touch the picker / display info), but listing
them here keeps the ``/`` menu single-sourced.
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any
logger = logging.getLogger("obsigate.skills")
SKILLS_FILE = Path("data/skills.json")
MAX_USER_SKILLS = 100
MAX_PROMPT_CHARS = 8000
_SKILL_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,47}$")
# ── Built-in skills ─────────────────────────────────────────────────────────
# ``prompt`` is appended to the assistant system prompt when the skill is
# selected. Keep prompts concise and language-agnostic: the model answers in
# the user's language.
BUILTIN_SKILLS: list[dict[str, Any]] = [
{
"id": "research",
"label": "Recherche structurée",
"icon": "🔎",
"type": "skill",
"description": "Recherche structurée + recommandation",
"prompt": (
"Applique un mode RECHERCHE STRUCTURÉE. Structure ta réponse en : "
"1) Contexte et question reformulée, 2) Constats appuyés sur le contenu fourni, "
"3) Options/approches avec avantages et limites, 4) Recommandation argumentée. "
"Cite les sources (fichiers) utilisées."
),
},
{
"id": "create-new-skill",
"label": "Créer un skill",
"icon": "🛠️",
"type": "skill",
"special": "create_skill",
"description": "Crée un workflow réutilisable (skill)",
"prompt": "",
},
{
"id": "resume",
"label": "Résumé",
"icon": "📄",
"type": "skill",
"description": "Résumé / synthèse structurée",
"prompt": (
"Produis un RÉSUMÉ structuré du contenu : idées clés, points importants, "
"conclusions. Utilise des titres et des puces concises."
),
},
{
"id": "actions",
"label": "Actions & to-dos",
"icon": "✅",
"type": "skill",
"description": "Extraire les actions & to-dos",
"prompt": (
"Extrais les ACTIONS et TO-DOS du contenu. Rends une liste de tâches markdown "
"`- [ ] ...`, avec responsable et échéance si mentionnés, sinon `(à préciser)`."
),
},
{
"id": "reformuler",
"label": "Reformuler",
"icon": "✍️",
"type": "skill",
"description": "Réécriture clarté / ton",
"prompt": (
"RÉÉCRIS le contenu pour améliorer la clarté et le ton, en préservant le sens. "
"Retourne uniquement le texte reformulé."
),
},
{
"id": "correction",
"label": "Correction",
"icon": "🔤",
"type": "skill",
"description": "Correction grammaire / orthographe / style",
"prompt": (
"CORRIGE la grammaire, l'orthographe et le style. Retourne le texte corrigé, "
"puis une courte liste des corrections notables."
),
},
{
"id": "brainstorm",
"label": "Brainstorm",
"icon": "💡",
"type": "skill",
"description": "Générer des idées, angles, variantes",
"prompt": (
"Mode BRAINSTORM : génère un maximum d'idées, angles et variantes pertinents. "
"Regroupe-les par thème, sans juger, puis signale les plus prometteuses."
),
},
{
"id": "plan",
"label": "Planifier",
"icon": "🧭",
"type": "skill",
"description": "Planifier / structurer un document",
"prompt": (
"PLANIFIE et structure un document : propose un plan détaillé (sections, "
"sous-sections, objectif de chaque partie) et une progression logique."
),
},
{
"id": "ask",
"label": "Q&R",
"icon": "💬",
"type": "skill",
"description": "Q&A sur un contenu référencé",
"prompt": (
"Mode QUESTION/RÉPONSE : réponds précisément à la question en te basant "
"strictement sur le contenu référencé. Cite les passages/fichiers utilisés et "
"dis clairement si l'information est absente."
),
},
{
"id": "meeting-note",
"label": "Note de réunion",
"icon": "📝",
"type": "skill",
"description": "Compte-rendu / note de réunion",
"prompt": (
"Rédige une NOTE DE RÉUNION : participants, ordre du jour, décisions, "
"points d'action (`- [ ] ...`), questions ouvertes et prochaines étapes."
),
},
{
"id": "livrable",
"label": "Livrable",
"icon": "📨",
"type": "skill",
"description": "Email / compte-rendu / message Slack",
"prompt": (
"Rédige un LIVRABLE de communication (email, compte-rendu ou message Slack) "
"clair et prêt à envoyer, adapté au canal et au destinataire indiqués."
),
},
]
# ── Admin commands (handled client-side) ────────────────────────────────────
ADMIN_COMMANDS: list[dict[str, Any]] = [
{
"id": "help",
"label": "Aide",
"icon": "❓",
"type": "admin",
"description": "Liste des commandes",
},
{
"id": "providers",
"label": "Fournisseurs",
"icon": "🔌",
"type": "admin",
"description": "Liste les fournisseurs actifs",
},
{
"id": "provider",
"label": "Changer de fournisseur",
"icon": "🔀",
"type": "admin",
"usage": "/provider <nom>",
"description": "Changer de fournisseur LLM",
},
{
"id": "model",
"label": "Changer de modèle",
"icon": "🧠",
"type": "admin",
"usage": "/model <nom>",
"description": "Changer de modèle LLM",
},
{
"id": "keys",
"label": "Clés API",
"icon": "🔑",
"type": "admin",
"description": "Fournisseurs avec clé API enregistrée",
},
]
def list_builtin_skills() -> list[dict[str, Any]]:
"""Return a copy of the built-in skills."""
return [dict(skill) for skill in BUILTIN_SKILLS]
def list_admin_commands() -> list[dict[str, Any]]:
"""Return a copy of the admin command metadata."""
return [dict(cmd) for cmd in ADMIN_COMMANDS]
def _read_store() -> dict[str, list[dict[str, Any]]]:
if not SKILLS_FILE.exists():
return {}
try:
data = json.loads(SKILLS_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception as exc: # pragma: no cover - corrupted file
logger.warning("Cannot read skills store: %s", exc)
return {}
def _write_store(store: dict[str, list[dict[str, Any]]]) -> None:
SKILLS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = SKILLS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(store, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(SKILLS_FILE)
def _username(user: dict | None) -> str:
if not user:
return "anonymous"
return str(user.get("username") or "anonymous")
def list_user_skills(user: dict | None) -> list[dict[str, Any]]:
"""Return the persisted custom skills for a user."""
store = _read_store()
skills = store.get(_username(user), [])
return [dict(s) for s in skills if isinstance(s, dict)]
def list_skills(user: dict | None) -> dict[str, list[dict[str, Any]]]:
"""Return built-in skills, admin commands and the user's custom skills."""
return {
"skills": list_builtin_skills() + list_user_skills(user),
"commands": list_admin_commands(),
}
def get_skill_prompt(skill_id: str | None, user: dict | None) -> str | None:
"""Resolve a skill id to its prompt, searching built-ins then user skills."""
if not skill_id:
return None
for skill in BUILTIN_SKILLS:
if skill["id"] == skill_id:
return skill.get("prompt") or None
for skill in list_user_skills(user):
if skill.get("id") == skill_id:
return skill.get("prompt") or None
return None
def create_user_skill(user: dict | None, payload: dict[str, Any]) -> dict[str, Any]:
"""Create and persist a custom skill for a user.
Raises:
ValueError: when the payload is invalid (bad id, duplicate, too many).
"""
skill_id = str(payload.get("id") or "").strip().lower()
label = str(payload.get("label") or "").strip()
prompt = str(payload.get("prompt") or "").strip()
description = str(payload.get("description") or "").strip()
if not _SKILL_ID_RE.match(skill_id):
raise ValueError("Identifiant invalide (a-z, 0-9, '-', '_', max 48)")
if any(s["id"] == skill_id for s in BUILTIN_SKILLS):
raise ValueError(f"L'identifiant '{skill_id}' est réservé")
if not label:
raise ValueError("Le nom du skill est requis")
if not prompt:
raise ValueError("Le prompt du skill est requis")
if len(prompt) > MAX_PROMPT_CHARS:
raise ValueError("Le prompt est trop long")
username = _username(user)
store = _read_store()
user_skills = store.get(username, [])
if any(s.get("id") == skill_id for s in user_skills):
raise ValueError(f"Le skill '{skill_id}' existe déjà")
if len(user_skills) >= MAX_USER_SKILLS:
raise ValueError("Trop de skills personnalisés")
skill = {
"id": skill_id,
"label": label,
"icon": str(payload.get("icon") or "🧩").strip() or "🧩",
"type": "skill",
"custom": True,
"description": description or label,
"prompt": prompt,
}
user_skills.append(skill)
store[username] = user_skills
_write_store(store)
return skill
def delete_user_skill(user: dict | None, skill_id: str) -> bool:
"""Delete a custom skill. Returns True when a skill was removed."""
username = _username(user)
store = _read_store()
user_skills = store.get(username, [])
remaining = [s for s in user_skills if s.get("id") != skill_id]
if len(remaining) == len(user_skills):
return False
store[username] = remaining
_write_store(store)
return True
+98
View File
@@ -0,0 +1,98 @@
"""API routes for AI assistant skills & slash-commands (``/api/ai/skills``)."""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from backend.auth.middleware import require_auth
from backend.skills import (
create_user_skill,
delete_user_skill,
list_skills,
)
logger = logging.getLogger("obsigate.skills_routes")
router = APIRouter(prefix="/api/ai/skills", tags=["AI"])
class SkillModel(BaseModel):
"""A single skill or admin command."""
model_config = {"extra": "allow"}
id: str
label: str
icon: str = "🧩"
type: str = Field(default="skill", description="'skill' or 'admin'")
description: str = ""
prompt: str | None = None
usage: str | None = None
special: str | None = None
custom: bool = False
class SkillsResponse(BaseModel):
"""Response for ``GET /api/ai/skills``."""
skills: list[SkillModel]
commands: list[SkillModel]
class CreateSkillRequest(BaseModel):
"""Body for ``POST /api/ai/skills``."""
id: str = Field(description="Stable identifier (a-z, 0-9, '-', '_')")
label: str = Field(description="Display name")
prompt: str = Field(description="Instruction injected into the system prompt")
description: str = ""
icon: str = "🧩"
class DeleteSkillResponse(BaseModel):
"""Response for ``DELETE /api/ai/skills/{skill_id}``."""
status: str = "deleted"
id: str
@router.get("", response_model=SkillsResponse)
async def api_list_skills(current_user=Depends(require_auth)):
"""List built-in skills, admin commands and the user's custom skills."""
data = list_skills(current_user)
return data
@router.post("", response_model=SkillModel)
async def api_create_skill(body: CreateSkillRequest, current_user=Depends(require_auth)):
"""Create a custom skill for the current user."""
try:
skill = create_user_skill(current_user, body.model_dump())
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return skill
@router.delete("/{skill_id}", response_model=DeleteSkillResponse)
async def api_delete_skill(skill_id: str, current_user=Depends(require_auth)):
"""Delete a custom skill owned by the current user."""
removed = delete_user_skill(current_user, skill_id)
if not removed:
raise HTTPException(status_code=404, detail="Skill introuvable")
return {"status": "deleted", "id": skill_id}
# Expose the resolved prompt of a skill (used by tests / clients that only
# need the instruction text without fetching the whole list).
@router.get("/{skill_id}/prompt")
async def api_skill_prompt(skill_id: str, current_user=Depends(require_auth)) -> dict[str, Any]:
"""Return the prompt text for a given skill id."""
from backend.skills import get_skill_prompt
prompt = get_skill_prompt(skill_id, current_user)
if prompt is None:
raise HTTPException(status_code=404, detail="Skill introuvable")
return {"id": skill_id, "prompt": prompt}