From a3642caa3dcfbca75b0b9264bc2a8681ce3b2cf7 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 11 Sep 2026 12:03:16 -0400 Subject: [PATCH] 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 --- backend/ai.py | 46 +++++- backend/ai_routes.py | 4 +- backend/main.py | 8 + docs/AI_ARCHITECTURE_GUIDE.md | 293 ++++++++++++++++++++++++++++++++++ frontend/index.html | 30 ++++ frontend/js/config.js | 76 ++++++++- frontend/locales/en.json | 1 + frontend/locales/fr.json | 1 + 8 files changed, 451 insertions(+), 8 deletions(-) create mode 100644 docs/AI_ARCHITECTURE_GUIDE.md diff --git a/backend/ai.py b/backend/ai.py index 02f18ff..95556cc 100644 --- a/backend/ai.py +++ b/backend/ai.py @@ -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 diff --git a/backend/ai_routes.py b/backend/ai_routes.py index 040c600..c161681 100644 --- a/backend/ai_routes.py +++ b/backend/ai_routes.py @@ -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, } diff --git a/backend/main.py b/backend/main.py index 93e72b7..15972d0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/docs/AI_ARCHITECTURE_GUIDE.md b/docs/AI_ARCHITECTURE_GUIDE.md new file mode 100644 index 0000000..6c5ada3 --- /dev/null +++ b/docs/AI_ARCHITECTURE_GUIDE.md @@ -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 diff --git a/frontend/index.html b/frontend/index.html index e8b2e98..5fcc0c6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1975,6 +1975,36 @@ Tester pour charger les modeles.

+
+ + +
+
+ + +