feat(ai): selection fournisseur/modele par defaut + guide architecture
- Persistance ai_default_provider / ai_default_models dans data/config.json - Lecture + rechargement a chaud dans backend/ai.py (get_default_provider, reload_ai_config) - UI: selecteurs Fournisseur/Modele par defaut dans la section Cles API IA (i18n FR/EN) - docs/AI_ARCHITECTURE_GUIDE.md: architecture cible, catalogue d'outils, decisions MCP
This commit is contained in:
+43
-3
@@ -18,6 +18,7 @@ ProviderName = Literal["deepseek", "openrouter", "gemini", "ollama", "nvidia", "
|
||||
|
||||
# Provider configurations — keys loaded from file or .env
|
||||
AI_KEYS_FILE = Path("data/api_keys.json")
|
||||
APP_CONFIG_FILE = Path(__file__).resolve().parent.parent / "data" / "config.json"
|
||||
|
||||
def _read_ai_keys() -> dict:
|
||||
if not AI_KEYS_FILE.exists():
|
||||
@@ -27,6 +28,16 @@ def _read_ai_keys() -> dict:
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _read_app_config() -> dict:
|
||||
"""Read the persisted application config (``data/config.json``)."""
|
||||
if not APP_CONFIG_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(APP_CONFIG_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_ai_key(env_name: str) -> str:
|
||||
"""Get AI key: stored file first, then .env fallback."""
|
||||
keys = _read_ai_keys()
|
||||
@@ -36,7 +47,7 @@ def get_ai_key(env_name: str) -> str:
|
||||
|
||||
def _load_provider_keys():
|
||||
"""Load AI keys from stored file, falling back to .env."""
|
||||
return {
|
||||
providers = {
|
||||
"deepseek": {
|
||||
"api_key": get_ai_key("DEEPSEEK_API_KEY"),
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
@@ -90,17 +101,46 @@ def _load_provider_keys():
|
||||
"auth_header": "Bearer {api_key}",
|
||||
},
|
||||
}
|
||||
# Apply persisted per-provider model overrides (data/config.json).
|
||||
overrides = _read_app_config().get("ai_default_models") or {}
|
||||
if isinstance(overrides, dict):
|
||||
for name, model in overrides.items():
|
||||
if name in providers and isinstance(model, str) and model:
|
||||
providers[name]["model"] = model
|
||||
return providers
|
||||
|
||||
|
||||
PROVIDERS = _load_provider_keys()
|
||||
|
||||
DEFAULT_PROVIDER: ProviderName = os.getenv("AI_DEFAULT_PROVIDER", "deepseek") # type: ignore
|
||||
|
||||
|
||||
def get_default_provider() -> str:
|
||||
"""Resolve the default provider: ``data/config.json`` > env > ``deepseek``."""
|
||||
provider = _read_app_config().get("ai_default_provider") or os.getenv("AI_DEFAULT_PROVIDER", "deepseek")
|
||||
return provider if provider in PROVIDERS else "deepseek"
|
||||
|
||||
|
||||
def reload_ai_config() -> str:
|
||||
"""Reload provider keys and model overrides from disk into ``PROVIDERS`` in place.
|
||||
|
||||
Mutating ``PROVIDERS`` (rather than rebinding it) keeps references held by
|
||||
other modules valid. Returns the resolved default provider.
|
||||
"""
|
||||
global DEFAULT_PROVIDER
|
||||
PROVIDERS.clear()
|
||||
PROVIDERS.update(_load_provider_keys())
|
||||
DEFAULT_PROVIDER = get_default_provider() # type: ignore[assignment]
|
||||
logger.info(f"AI config reloaded (default provider: {DEFAULT_PROVIDER})")
|
||||
return DEFAULT_PROVIDER
|
||||
|
||||
|
||||
def _get_provider_config(provider: ProviderName | None = None) -> dict:
|
||||
"""Get provider config, falling back to default if requested provider unavailable."""
|
||||
p = provider or DEFAULT_PROVIDER
|
||||
default = get_default_provider()
|
||||
p = provider or default
|
||||
if p not in PROVIDERS:
|
||||
p = DEFAULT_PROVIDER
|
||||
p = default
|
||||
cfg = PROVIDERS[p]
|
||||
if not cfg["api_key"]:
|
||||
# Try next available provider
|
||||
|
||||
@@ -6,7 +6,6 @@ from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.ai import (
|
||||
DEFAULT_PROVIDER,
|
||||
PROVIDERS,
|
||||
ai_change_tone,
|
||||
ai_continue_writing,
|
||||
@@ -24,6 +23,7 @@ from backend.ai import (
|
||||
ai_simplify,
|
||||
ai_summarize,
|
||||
ai_translate,
|
||||
get_default_provider,
|
||||
)
|
||||
from backend.schemas import AIStatusResponse
|
||||
|
||||
@@ -91,7 +91,7 @@ async def api_status():
|
||||
|
||||
return {
|
||||
"configured": any(p["available"] for p in providers.values()),
|
||||
"default_provider": DEFAULT_PROVIDER,
|
||||
"default_provider": get_default_provider(),
|
||||
"providers": providers,
|
||||
"autocomplete": autocomplete,
|
||||
}
|
||||
|
||||
@@ -4286,6 +4286,8 @@ _DEFAULT_CONFIG = {
|
||||
"prefix_max_expansions": 50,
|
||||
"recent_files_limit": 20,
|
||||
"max_backups_per_file": 10,
|
||||
"ai_default_provider": "deepseek",
|
||||
"ai_default_models": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -4341,6 +4343,12 @@ async def api_set_config(body: dict = Body(...), current_user=Depends(require_ad
|
||||
detail=f"Invalid type for '{key}': expected {expected_type.__name__}, got {type(value).__name__}",
|
||||
)
|
||||
_save_config(current)
|
||||
if any(k.startswith("ai_") for k in updated_keys):
|
||||
try:
|
||||
from backend.ai import reload_ai_config
|
||||
reload_ai_config()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to reload AI config: {e}")
|
||||
logger.info(f"Config updated: {updated_keys}")
|
||||
return current
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# ObsiGate — Guide d'architecture IA
|
||||
|
||||
> **Statut :** document de conception (référence pour l'implémentation)
|
||||
> **Dernière mise à jour :** 2026-09-11
|
||||
> **Portée :** sous-systèmes IA d'ObsiGate, couche d'outils (function calling), serveur MCP, sélection du fournisseur/modèle par défaut.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vue d'ensemble
|
||||
|
||||
ObsiGate possède aujourd'hui **deux sous-systèmes IA** qui partagent la même couche de fournisseurs (`backend/ai.py`) :
|
||||
|
||||
| Sous-système | Rôle | Backend | Frontend |
|
||||
|---|---|---|---|
|
||||
| **Barre d'outils IA de l'éditeur** | 16 transformations de texte statiques (améliorer, traduire, résumer…) | `backend/ai.py`, `backend/ai_routes.py` | `frontend/js/ai.js` |
|
||||
| **Assistant BooksLM** | Chat contextuel (dossier / documents / général) | `backend/bookslm.py`, `backend/bookslm_routes.py` | `frontend/js/bookslm.js` |
|
||||
|
||||
**Constat clé :** il n'existe **aucun tool calling / function calling natif ni MCP**. La seule « action » de l'assistant repose sur un protocole texte maison (`obsigate-action`) limité à `create_file` et `create_directory`.
|
||||
|
||||
### Objectif cible
|
||||
|
||||
1. Un **assistant in-app** capable de lire, chercher, lister, ouvrir et modifier, via **function calling natif**.
|
||||
2. Un **serveur MCP** exposant ObsiGate aux clients externes (Claude Desktop, Cursor…).
|
||||
3. Les deux fronts consomment **la même couche d'outils** — source unique de vérité.
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Couche d'outils partagée │
|
||||
│ registry + services + perms │
|
||||
└───────┬───────────────┬───────┘
|
||||
│ │
|
||||
┌─────────────▼─────┐ ┌─────▼──────────────────┐
|
||||
│ Assistant in-app │ │ Serveur MCP (externe) │
|
||||
│ function calling │ │ Claude Desktop, Cursor │
|
||||
│ + confirmations UI│ │ tools + resources │
|
||||
└───────────────────┘ └────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. État actuel (références de code)
|
||||
|
||||
### 2.1 Barre d'outils IA de l'éditeur
|
||||
|
||||
- `ai_complete()` — `backend/ai.py:175`
|
||||
- 16 endpoints REST `POST /api/ai/*` — `backend/ai_routes.py:135+`
|
||||
- Requête `AIRequest` (`text`, `instruction`, `target_lang`, `tone`, `provider`, `model`) — `backend/ai_routes.py:100`
|
||||
- Statut `GET /api/ai/status` — `backend/ai_routes.py:34`
|
||||
|
||||
### 2.2 Assistant BooksLM
|
||||
|
||||
- Collecte du contexte : `collect_directory_context()` `backend/bookslm.py:69`, `collect_files_context()` `backend/bookslm.py:209`
|
||||
- Arborescence : `_build_directory_tree()` `backend/bookslm.py:276`
|
||||
- Limites : `BOOKSLM_MAX_FILES=200`, `BOOKSLM_MAX_TOTAL_CHARS=200000`, `BOOKSLM_MAX_FILE_CHARS=30000` — `backend/bookslm.py:21`
|
||||
- Cache 5 min indexé par mtime — `backend/bookslm.py:26`, `:111`
|
||||
- Prompts système : `build_system_prompt()` `backend/bookslm.py:295`, `build_general_system_prompt()` `backend/bookslm.py:380`
|
||||
- Protocole d'action : `GENERAL_SYSTEM_PROMPT` `backend/bookslm.py:353-377`
|
||||
- Routes : `POST /api/ai/bookslm/context`, `POST /api/ai/bookslm/chat` — `backend/bookslm_routes.py:96,118`
|
||||
- Extraction/application d'action côté client : `_extractActions()` `frontend/js/bookslm.js:502`, `_applyAction()` `frontend/js/bookslm.js:550`
|
||||
|
||||
### 2.3 Couche fournisseurs
|
||||
|
||||
- `PROVIDERS` (dict module, chargé une fois à l'import) — `backend/ai.py:37-94`
|
||||
- `DEFAULT_PROVIDER` — `backend/ai.py:96`
|
||||
- Appel OpenAI-compatible : `_call_deepseek_openrouter()` — `backend/ai.py:115`
|
||||
- Appel Gemini : `_call_gemini()` — `backend/ai.py:156`
|
||||
- Clés stockées dans `data/api_keys.json`, fallback `.env` — `get_ai_key()` `backend/ai.py:30`
|
||||
- Surcharge de modèle par requête : `_handle()` `backend/ai_routes.py:114`, `bookslm_routes.py:194`
|
||||
|
||||
### 2.4 Sécurité existante à réutiliser
|
||||
|
||||
| Mécanisme | Référence |
|
||||
|---|---|
|
||||
| Auth JWT | `require_auth` `backend/auth/middleware.py:69` |
|
||||
| Admin | `require_admin` `backend/auth/middleware.py:80` |
|
||||
| Accès vault | `check_vault_access` `backend/auth/middleware.py:87`, `require_vault_access` `:101` |
|
||||
| Anti path-traversal | `_resolve_safe_path` `backend/main.py:867` |
|
||||
| Redaction secrets | `backend/secret_redactor.py` (`redact_file_content`) |
|
||||
| Audit | `data/audit.log` |
|
||||
|
||||
### 2.5 Limites de l'existant
|
||||
|
||||
1. **Pas de tool calling** : parsing regex fragile, pas de résultats structurés, pas de multi-étapes.
|
||||
2. **2 actions seulement** (`create_file`, `create_directory`) ; ni lecture active, ni recherche, ni édition, ni suppression exposées au modèle.
|
||||
3. **SSE non réellement streaming** : la réponse complète est envoyée en un seul événement — `backend/bookslm_routes.py:211`.
|
||||
4. **`PROVIDERS` chargé une seule fois** : les modèles par défaut ne sont modifiables que par variables d'environnement (pas de persistance UI).
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture cible
|
||||
|
||||
### 3.1 Couche 1 — Capacités (services backend)
|
||||
|
||||
Extraire la logique métier des routes de `backend/main.py` vers des fonctions réutilisables (services). Les routes REST, l'agent in-app et le serveur MCP appellent ces mêmes services.
|
||||
|
||||
**Nouveau module `backend/tools/` :**
|
||||
|
||||
```
|
||||
backend/tools/
|
||||
├── __init__.py
|
||||
├── context.py # ToolContext (user, allowed_vaults, mode, confirmed)
|
||||
├── registry.py # décorateur @tool(...) + schémas JSON
|
||||
├── schemas.py # modèles Pydantic entrée/sortie
|
||||
├── service.py # implémentations (wrappers des services métier)
|
||||
└── audit.py # journalisation des appels d'outils
|
||||
```
|
||||
|
||||
### 3.2 Couche 2 — Registry d'outils
|
||||
|
||||
Chaque outil déclare : `name`, `description`, `parameters` (JSON Schema), `risk` (`read` | `write` | `dangerous`), `requires_confirmation` (bool), `scopes` (`in_app`, `mcp`).
|
||||
|
||||
### 3.3 Couche 3 — Agent loop (in-app)
|
||||
|
||||
Nouveau `backend/agent/loop.py` :
|
||||
|
||||
```
|
||||
boucle (max N itérations):
|
||||
réponse = LLM(messages, tools)
|
||||
si tool_calls:
|
||||
pour chaque appel:
|
||||
vérifier permissions + confirmation
|
||||
exécuter via la couche d'outils (ToolContext)
|
||||
réinjecter le résultat comme message "tool"
|
||||
continuer
|
||||
sinon:
|
||||
renvoyer le texte final
|
||||
```
|
||||
|
||||
- Fallback protocole texte (`obsigate-action`) si le modèle ne supporte pas les tools.
|
||||
- SSE réellement streaming.
|
||||
|
||||
### 3.4 Couche 4 — Serveur MCP
|
||||
|
||||
Nouveau `backend/mcp/server.py` basé sur le SDK MCP Python :
|
||||
|
||||
- **Tools** : enregistrés depuis le registry (mutations + recherche).
|
||||
- **Resources** : vaults/fichiers en lecture (`vault://name/path.md`).
|
||||
- **Prompts** : templates (résumer un dossier, générer une note…).
|
||||
- Transports : **stdio** (local) et **HTTP/SSE** (distant, auth JWT).
|
||||
|
||||
---
|
||||
|
||||
## 4. Catalogue des outils
|
||||
|
||||
Légende : **R** = lecture (auto), **M** = mutation (confirmation), **N** = navigation (in-app uniquement).
|
||||
|
||||
### Vaults & navigation
|
||||
|
||||
| Outil | Type | Paramètres | Endpoint source |
|
||||
|---|---|---|---|
|
||||
| `list_vaults` | R | — | `GET /api/vaults` (`main.py:1267`) |
|
||||
| `list_directory` | R | `vault`, `path` | `GET /api/browse/{vault}` (`main.py:1503`) |
|
||||
| `list_all_files` | R | `vault` | `GET /api/vault/{vault}/files` (`main.py:3883`) |
|
||||
| `open_file` | N | `vault`, `path` | événement front `obsigate:open-file` |
|
||||
| `reveal_in_tree` | N | `vault`, `path` | UI sidebar |
|
||||
|
||||
### Lecture de contenu
|
||||
|
||||
| Outil | Type | Paramètres | Endpoint source |
|
||||
|---|---|---|---|
|
||||
| `read_file` | R | `vault`, `path` | `GET /api/file/{vault}` (`main.py:2722`) |
|
||||
| `read_file_raw` | R | `vault`, `path` | `GET /api/file/{vault}/raw` (`main.py:1589`) |
|
||||
| `get_backlinks` | R | `vault`, `path` | `GET /api/file/{vault}/backlinks` (`main.py:2683`) |
|
||||
| `list_backups` | R | `vault`, `path` | `GET /api/file/{vault}/backups` (`main.py:2476`) |
|
||||
| `diff_backup` | R | `vault`, `path` | `GET /api/file/{vault}/diff` (`main.py:2513`) |
|
||||
| `get_graph` | R | `vault` | `GET /api/graph/{vault}` (`main.py:3390`) |
|
||||
|
||||
### Recherche
|
||||
|
||||
| Outil | Type | Paramètres | Endpoint source |
|
||||
|---|---|---|---|
|
||||
| `search_fulltext` | R | `q`, `vault`, `tag`, `limit` | `GET /api/search` (`main.py:3097`) |
|
||||
| `search_advanced` | R | `q`, `vault`, filtres, `regex`… | `GET /api/search/advanced` (`main.py:3195`) |
|
||||
| `search_paths` | R | `q`, `vault` | `GET /api/tree-search` (`main.py:3151`) |
|
||||
| `list_tags` | R | `vault` | `GET /api/tags` (`main.py:3137`) |
|
||||
| `suggest_tags` | R | `q` | `GET /api/tags/suggest` (`main.py:3352`) |
|
||||
| `list_recent` | R | `vault` | `GET /api/recent` (`main.py:1301`) |
|
||||
|
||||
### Création / modification
|
||||
|
||||
| Outil | Type | Paramètres | Endpoint source |
|
||||
|---|---|---|---|
|
||||
| `create_file` | M | `vault`, `path`, `content` | `POST /api/file/{vault}` (`main.py:2134`) |
|
||||
| `create_directory` | M | `vault`, `path` | `POST /api/directory/{vault}` (`main.py:1921`) |
|
||||
| `edit_file` | M | `vault`, `path`, `content` | `PUT /api/file/{vault}/save` (`main.py:1796`) |
|
||||
| `append_to_file` | M | `vault`, `path`, `content` | dérivé de save |
|
||||
| `rename_file` | M | `vault`, `path`, `new_name` | `PATCH /api/file/{vault}` (`main.py:2207`) |
|
||||
| `rename_directory` | M | `vault`, `path`, `new_name` | `PATCH /api/directory/{vault}` (`main.py:1995`) |
|
||||
| `move_path` | M | `vault`, `src`, `dst` | `POST /api/move/{vault}` (`main.py:2289`) |
|
||||
| `replace_in_files` | M ⚠️ | `vault`, `find`, `replace`, filtres | `POST /api/search/replace` (`main.py:3241`) |
|
||||
|
||||
### Suppression (confirmation renforcée)
|
||||
|
||||
| Outil | Type | Paramètres | Endpoint source |
|
||||
|---|---|---|---|
|
||||
| `delete_file` | M ⚠️ | `vault`, `path` | `DELETE /api/file/{vault}` (`main.py:1851`) |
|
||||
| `delete_directory` | M ⚠️ | `vault`, `path`, `recursive` | `DELETE /api/directory/{vault}` (`main.py:2068`) |
|
||||
| `restore_backup` | M | `vault`, `path` | `POST /api/file/{vault}/restore` (`main.py:2601`) |
|
||||
|
||||
### Partage (optionnel, admin)
|
||||
|
||||
| Outil | Type | Paramètres | Endpoint source |
|
||||
|---|---|---|---|
|
||||
| `create_share` | M | `vault`, `path`, options | `POST /api/share/{vault}` (`main.py:4719`) |
|
||||
| `list_shares` | R | — | — |
|
||||
|
||||
---
|
||||
|
||||
## 5. Sécurité & permissions
|
||||
|
||||
Tout outil reçoit un `ToolContext` et applique **systématiquement** :
|
||||
|
||||
1. `check_vault_access(vault, user)` — `backend/auth/middleware.py:87`
|
||||
2. `_resolve_safe_path(vault_root, path)` — `backend/main.py:867`
|
||||
3. Confirmation utilisateur pour `risk in (write, dangerous)` (UI in-app : carte Apply ; MCP : two-step `propose`/`apply`).
|
||||
4. Redaction des secrets avant tout envoi au LLM — `redact_file_content()`.
|
||||
5. Journalisation dans l'audit (`data/audit.log`).
|
||||
6. Rate limiting par token/outil ; quotas réutilisant `BOOKSLM_MAX_*`.
|
||||
|
||||
### Confirmations MCP — décision : two-step `propose`/`apply`
|
||||
|
||||
MCP n'a pas de bouton « Apply ». Mécanisme retenu :
|
||||
|
||||
- **Two-step (canonique)** : `propose_*` (non destructif) renvoie un **aperçu/diff** + un **`confirmation_token`** signé (usage unique, TTL) ; `apply_*` exige ce token pour exécuter. Universel (tout client), anti-rejeu/anti-TOCTOU, et **unifie in-app et MCP** (la carte Apply de l'UI est un `propose`→`apply`).
|
||||
- **Élicitation (optionnelle, plus tard)** : quand le client l'annonce dans ses `capabilities`, afficher la confirmation inline. Fallback two-step sinon.
|
||||
|
||||
Les outils destructifs (`delete_*`, `rename_*`, `move_path`, `replace_in_files`) sont **autorisés** (ObsiGate + MCP = outil de gestion des vaults pour des agents), mais encadrés :
|
||||
|
||||
- **Confirmation two-step obligatoire** (`propose_*` → `apply_*`, token signé).
|
||||
- **Backup automatique** avant toute opération destructive (mécanisme existant, `POST /api/file/{vault}/restore`).
|
||||
- **Audit** systématique (`data/audit.log`).
|
||||
- **Toggle par vault** pour désactiver les outils destructifs (défaut : activés).
|
||||
|
||||
---
|
||||
|
||||
## 6. Sélection du fournisseur et des modèles par défaut
|
||||
|
||||
Pour répondre au besoin de cibler un fournisseur/modèle sans dépendre uniquement des variables d'environnement, la configuration expose deux paramètres persistés dans `data/config.json` :
|
||||
|
||||
| Clé | Type | Défaut | Rôle |
|
||||
|---|---|---|---|
|
||||
| `ai_default_provider` | str | `deepseek` | Fournisseur utilisé quand aucun override n'est fourni |
|
||||
| `ai_default_models` | dict | `{}` | Modèle par défaut par fournisseur (ex. `{"deepseek": "deepseek-chat"}`) |
|
||||
|
||||
- Lecture : `backend/ai.py` (`_read_app_config`, `get_default_provider`, `_load_provider_keys`).
|
||||
- Écriture : `POST /api/config` (admin) — clés ajoutées à `_DEFAULT_CONFIG` (`backend/main.py:4270`).
|
||||
- Rechargement à chaud : `reload_ai_config()` met à jour `PROVIDERS` **en place** (les imports existants restent valides).
|
||||
- UI : section « Clés API Intelligence Artificielle » (`frontend/index.html` `#cfg-ai`), sélecteurs « Fournisseur par défaut » + « Modèle par défaut », sauvegardés par `saveAIKeys()` (`frontend/js/config.js`).
|
||||
|
||||
**Précédence de résolution du modèle** : override par requête > `ai_default_models[provider]` > variable d'environnement `*_MODEL` > défaut codé en dur.
|
||||
|
||||
**Précédence du fournisseur** : override par requête > `ai_default_provider` > env `AI_DEFAULT_PROVIDER` > `deepseek`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Plan par phases
|
||||
|
||||
| Phase | Contenu | Livrable |
|
||||
|---|---|---|
|
||||
| **0 — Fondations** | `backend/tools/` (registry, context, service, audit) + extraction des services métier + tests unitaires | Couche d'outils testable sans IA |
|
||||
| **1 — Function calling in-app** | Abstraction tool-calling multi-provider, agent loop, confirmations UI, SSE réel, outils de navigation | Assistant qui lit/cherche/lit/ouvre/modifie avec confirmation |
|
||||
| **2 — Serveur MCP** | `backend/mcp/server.py` (tools + resources + prompts), **Streamable HTTP** (`/mcp`, auth JWT), confirmation two-step | ObsiGate accessible comme serveur MCP (local + distant, multi-utilisateur) |
|
||||
| **3 — Durcissement** | Rate limiting, quotas, redaction, tests, doc OpenAPI/MCP | Observabilité et sécurité complètes |
|
||||
|
||||
Voir `docs/ROADMAP.md` (item dédié) pour le détail des activités.
|
||||
|
||||
---
|
||||
|
||||
## 8. Décisions
|
||||
|
||||
| # | Décision | Statut |
|
||||
|---|---|---|
|
||||
| 1 | **Transport MCP** : **Streamable HTTP** (`/mcp` dans FastAPI existant). `stdio` optionnel plus tard pour clients locaux sans MCP distant. | ✅ Décidé (2026-09-11) |
|
||||
| 2 | **Confirmation MCP** : **two-step `propose`/`apply`** (token signé). Élicitation en bonus quand le client l'annonce. | ✅ Décidé (2026-09-11) |
|
||||
| 3 | **Périmètre des mutations externes** : **toutes autorisées** via MCP — `create`, `edit`, `rename`, `move`, `delete` (ObsiGate + MCP = outil de gestion vault↔agents). Encadrées par confirmation two-step + backup auto + audit + toggle par vault. | ✅ Décidé (2026-09-11) |
|
||||
| 4 | **Provider tool-calling** : DeepSeek par défaut → adressé par la sélection du fournisseur/modèle par défaut (§6). | ✅ Résolu |
|
||||
|
||||
### Justification
|
||||
|
||||
- **HTTP** : ObsiGate est déjà un serveur web avec JWT et permissions par vault ; exposer `/mcp` réutilise l'auth, le multi-utilisateur et l'accès distant. `stdio` imposerait un process séparé dupliquant l'infra pour un usage local mono-utilisateur.
|
||||
- **Two-step** : universel (aucune dépendance aux capacités du client), permet un diff avant application, token signé à usage unique (anti-rejeu), et cohérent avec le flux in-app existant.
|
||||
- **Mutations complètes** : l'objectif est de faire d'ObsiGate + MCP la couche de gestion entre les vaults et des agents ; restreindre delete/rename/move limiterait fortement les cas d'usage. La sécurité repose sur la confirmation, le backup automatique, l'audit et le toggle par vault plutôt que sur une interdiction par défaut.
|
||||
|
||||
---
|
||||
|
||||
## 9. Références
|
||||
|
||||
- `backend/ai.py`, `backend/ai_routes.py` — couche fournisseurs + actions éditeur
|
||||
- `backend/bookslm.py`, `backend/bookslm_routes.py` — assistant contextuel
|
||||
- `frontend/js/ai.js`, `frontend/js/bookslm.js` — UI IA
|
||||
- `backend/auth/middleware.py` — permissions
|
||||
- `backend/secret_redactor.py` — redaction
|
||||
- `docs/ROADMAP.md` — suivi des phases
|
||||
@@ -1975,6 +1975,36 @@
|
||||
<strong data-i18n="config.test">Tester</strong> pour charger les
|
||||
modeles.
|
||||
</p>
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
for="cfg-ai-default-provider"
|
||||
data-i18n="config.ai_default_provider"
|
||||
>Fournisseur par defaut</label
|
||||
>
|
||||
<select
|
||||
id="cfg-ai-default-provider"
|
||||
class="config-select"
|
||||
style="width: 200px"
|
||||
>
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
for="cfg-ai-default-model"
|
||||
data-i18n="config.ai_default_model"
|
||||
>Modele par defaut</label
|
||||
>
|
||||
<select
|
||||
id="cfg-ai-default-model"
|
||||
class="config-select"
|
||||
style="width: 220px"
|
||||
>
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
|
||||
+73
-3
@@ -615,8 +615,19 @@ function initConfigModal() {
|
||||
if (saveAIKeysBtn) saveAIKeysBtn.addEventListener("click", saveAIKeys);
|
||||
const testAIKeysBtn = document.getElementById("cfg-test-ai-keys");
|
||||
if (testAIKeysBtn) testAIKeysBtn.addEventListener("click", testAIKeys);
|
||||
// Load existing keys on modal open
|
||||
// Default provider/model selection
|
||||
const aiDefaultProviderSel = document.getElementById("cfg-ai-default-provider");
|
||||
if (aiDefaultProviderSel) {
|
||||
aiDefaultProviderSel.addEventListener("change", async () => {
|
||||
let cfg = {};
|
||||
try { cfg = await api("/api/config"); } catch(e) {}
|
||||
const models = cfg.ai_default_models || {};
|
||||
await _populateAIModelOptions(aiDefaultProviderSel.value, models[aiDefaultProviderSel.value] || "");
|
||||
});
|
||||
}
|
||||
// Load existing keys/defaults on modal open
|
||||
document.getElementById("config-open-btn").addEventListener("click", loadAIKeys);
|
||||
document.getElementById("config-open-btn").addEventListener("click", loadAIDefaults);
|
||||
|
||||
// Profile menu button: open config modal and scroll to profile section
|
||||
var profileBtn = document.getElementById("profile-open-btn");
|
||||
@@ -1407,18 +1418,77 @@ async function loadAIKeys() {
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function loadAIDefaults() {
|
||||
const providerSel = document.getElementById("cfg-ai-default-provider");
|
||||
if (!providerSel) return;
|
||||
if (!providerSel.dataset.populated) {
|
||||
providerSel.innerHTML = '<option value="">--</option>';
|
||||
AI_PROVIDER_NAMES.forEach(function(p) {
|
||||
providerSel.innerHTML += '<option value="' + p + '">' + p + '</option>';
|
||||
});
|
||||
providerSel.dataset.populated = "1";
|
||||
}
|
||||
let cfg = {};
|
||||
try { cfg = await api("/api/config"); } catch(e) {}
|
||||
const provider = cfg.ai_default_provider || "deepseek";
|
||||
providerSel.value = provider;
|
||||
const models = cfg.ai_default_models || {};
|
||||
await _populateAIModelOptions(provider, models[provider] || "");
|
||||
}
|
||||
|
||||
async function _populateAIModelOptions(provider, selected) {
|
||||
const modelSel = document.getElementById("cfg-ai-default-model");
|
||||
if (!modelSel) return;
|
||||
modelSel.innerHTML = '<option value="">--</option>';
|
||||
if (!provider) return;
|
||||
try {
|
||||
const m = await api("/api/config/ai-models?provider=" + encodeURIComponent(provider));
|
||||
const list = (m && m.models) || [];
|
||||
list.forEach(function(md) {
|
||||
modelSel.innerHTML += '<option value="' + md + '">' + md + '</option>';
|
||||
});
|
||||
if (selected && list.indexOf(selected) === -1) {
|
||||
modelSel.innerHTML += '<option value="' + selected + '">' + selected + '</option>';
|
||||
}
|
||||
modelSel.value = selected || "";
|
||||
} catch(e) { console.warn("Failed to load default models for", provider, e); }
|
||||
}
|
||||
|
||||
async function saveAIKeys() {
|
||||
const keys = {};
|
||||
for (const [id, name] of Object.entries(AI_KEY_MAP)) {
|
||||
const input = document.getElementById(id);
|
||||
if (input && input.value.trim()) keys[name] = input.value.trim();
|
||||
}
|
||||
if (!Object.keys(keys).length) { showToast(t("config.no_keys"), "warning"); return; }
|
||||
// Collect default provider + per-provider default models
|
||||
const providerSel = document.getElementById("cfg-ai-default-provider");
|
||||
const modelSel = document.getElementById("cfg-ai-default-model");
|
||||
const defaults = {};
|
||||
const models = {};
|
||||
for (const p of AI_PROVIDER_NAMES) {
|
||||
const sel = document.getElementById("cfg-" + p + "-model");
|
||||
if (sel && sel.value) models[p] = sel.value;
|
||||
}
|
||||
if (providerSel && providerSel.value) {
|
||||
defaults.ai_default_provider = providerSel.value;
|
||||
if (modelSel && modelSel.value) models[providerSel.value] = modelSel.value;
|
||||
}
|
||||
if (Object.keys(models).length) defaults.ai_default_models = models;
|
||||
|
||||
if (!Object.keys(keys).length && !Object.keys(defaults).length) {
|
||||
showToast(t("config.no_keys"), "warning"); return;
|
||||
}
|
||||
try {
|
||||
await api("/api/config/ai-keys", { method: "POST", body: JSON.stringify(keys) });
|
||||
if (Object.keys(keys).length) {
|
||||
await api("/api/config/ai-keys", { method: "POST", body: JSON.stringify(keys) });
|
||||
}
|
||||
if (Object.keys(defaults).length) {
|
||||
await api("/api/config", { method: "POST", body: JSON.stringify(defaults) });
|
||||
}
|
||||
showToast(t("config.api_keys_saved"), "success");
|
||||
Object.keys(AI_KEY_MAP).forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; });
|
||||
loadAIKeys();
|
||||
loadAIDefaults();
|
||||
} catch(e) { showToast("Erreur: " + e.message, "error"); }
|
||||
}
|
||||
|
||||
|
||||
@@ -306,6 +306,7 @@
|
||||
"config.about_loading": "Loading...",
|
||||
"config.add_webhook": "Add",
|
||||
"config.ai_deepseek_label": "DeepSeek API Key",
|
||||
"config.ai_default_model": "Default model",
|
||||
"config.ai_default_provider": "Default provider",
|
||||
"config.ai_gemini_label": "Gemini API Key",
|
||||
"config.ai_keys": "AI API keys",
|
||||
|
||||
@@ -306,6 +306,7 @@
|
||||
"config.about_loading": "Chargement...",
|
||||
"config.add_webhook": "Ajouter",
|
||||
"config.ai_deepseek_label": "DeepSeek API Key",
|
||||
"config.ai_default_model": "Modèle par défaut",
|
||||
"config.ai_default_provider": "Fournisseur par défaut",
|
||||
"config.ai_gemini_label": "Gemini API Key",
|
||||
"config.ai_keys": "Clés API IA",
|
||||
|
||||
Reference in New Issue
Block a user