From 736ed2809ff2dc431cf445aab84fef6b36201eb2 Mon Sep 17 00:00:00 2001 From: Axel Duviella Date: Tue, 28 Jul 2026 08:41:47 +0200 Subject: [PATCH 1/2] feat(hmi): sync verite d'abord depuis moteur #157 Backend metrics/HA/vocal/setup + IHM sans mocks ; IPs LAN anonymisees. Co-authored-by: Cursor --- .env.example | 160 ++-- agents/dreamer_agent.py | 754 +++++++++++------- api/routes/billing.py | 298 ++++++- api/routes/ha.py | 193 ++++- api/routes/metrics.py | 290 ++++++- api/routes/setup.py | 299 +++++++ api/routes/vocal.py | 160 ++++ core/budget_guard.py | 283 +++++-- core/ha_tls.py | 98 ++- core/llm_gateway.py | 100 ++- core/mcp_tools/homeassistant.py | 40 +- core/openai_compat_provider.py | 21 +- core/runtime_db.py | 37 + core/tab5_pusher.py | 27 +- core/vocal_tools.py | 45 +- gui_server.py | 69 +- ihm-v2/README.md | 88 ++ ihm-v2/src/App.tsx | 93 +++ ihm-v2/src/api/accounts.ts | 64 ++ ihm-v2/src/api/autonomy.ts | 81 ++ ihm-v2/src/api/backlog.ts | 82 ++ ihm-v2/src/api/benchmarks.ts | 110 +++ ihm-v2/src/api/ha.ts | 118 +++ ihm-v2/src/api/setup.ts | 39 + ihm-v2/src/api/workflows.ts | 139 ++++ .../components/config/ConfigFieldControl.tsx | 204 +++++ ihm-v2/src/components/layout/AppLayout.tsx | 179 +++++ ihm-v2/src/components/ui/primitives.tsx | 389 +++++++++ ihm-v2/src/main.tsx | 30 + ihm-v2/src/state/uiStore.ts | 76 ++ ihm-v2/src/types/config.ts | 598 ++++++++++++++ ihm-v2/src/views/APIAccounts.tsx | 216 +++++ ihm-v2/src/views/Autonomy.tsx | 477 +++++++++++ ihm-v2/src/views/Benchmarks.tsx | 488 ++++++++++++ ihm-v2/src/views/Configuration.tsx | 240 ++++++ ihm-v2/src/views/Docs.tsx | 600 ++++++++++++++ ihm-v2/src/views/HomeAssistant.tsx | 538 +++++++++++++ ihm-v2/src/views/Setup.tsx | 170 ++++ ihm-v2/src/views/TaskRunner.tsx | 399 +++++++++ ihm-v2/src/views/WorkflowBuilder.tsx | 355 +++++++++ seed_models_db.py | 209 +++-- tests/unit/test_dashscope_provider.py | 95 +++ tests/unit/test_ha_tls.py | 75 ++ tests/unit/test_vocal_tools.py | 37 +- 44 files changed, 8460 insertions(+), 603 deletions(-) create mode 100644 api/routes/setup.py create mode 100644 api/routes/vocal.py create mode 100644 ihm-v2/README.md create mode 100644 ihm-v2/src/App.tsx create mode 100644 ihm-v2/src/api/accounts.ts create mode 100644 ihm-v2/src/api/autonomy.ts create mode 100644 ihm-v2/src/api/backlog.ts create mode 100644 ihm-v2/src/api/benchmarks.ts create mode 100644 ihm-v2/src/api/ha.ts create mode 100644 ihm-v2/src/api/setup.ts create mode 100644 ihm-v2/src/api/workflows.ts create mode 100644 ihm-v2/src/components/config/ConfigFieldControl.tsx create mode 100644 ihm-v2/src/components/layout/AppLayout.tsx create mode 100644 ihm-v2/src/components/ui/primitives.tsx create mode 100644 ihm-v2/src/main.tsx create mode 100644 ihm-v2/src/state/uiStore.ts create mode 100644 ihm-v2/src/types/config.ts create mode 100644 ihm-v2/src/views/APIAccounts.tsx create mode 100644 ihm-v2/src/views/Autonomy.tsx create mode 100644 ihm-v2/src/views/Benchmarks.tsx create mode 100644 ihm-v2/src/views/Configuration.tsx create mode 100644 ihm-v2/src/views/Docs.tsx create mode 100644 ihm-v2/src/views/HomeAssistant.tsx create mode 100644 ihm-v2/src/views/Setup.tsx create mode 100644 ihm-v2/src/views/TaskRunner.tsx create mode 100644 ihm-v2/src/views/WorkflowBuilder.tsx create mode 100644 tests/unit/test_dashscope_provider.py create mode 100644 tests/unit/test_ha_tls.py diff --git a/.env.example b/.env.example index dabd602..d35fb76 100644 --- a/.env.example +++ b/.env.example @@ -1,114 +1,110 @@ # ───────────────────────────────────────────────────────────────────────── -# tab5-engine — Variables d'environnement -# Copier ce fichier en `.env` et renseigner les valeurs. -# NE JAMAIS committer le `.env` réel (il est dans .gitignore). -# -# Le moteur fonctionne avec UNE SEULE clé gratuite (GEMINI_API_KEY). -# Tout le reste est optionnel — les providers absents sont simplement -# désactivés et le moteur se dégrade gracieusement. +# Moteur Multi-Agents — Variables d'environnement (exemple) +# Copier en `.env` et renseigner. NE JAMAIS committer le `.env` réel. +# Liste dérivée du code réel (os.environ.get). Tout est OPTIONNEL sauf mention +# « REQUIS » : une clé de provider absente = ce provider est simplement désactivé. # ───────────────────────────────────────────────────────────────────────── -# ════════════════════════════════════════════════════════════ -# SECTION 1 — SÉCURITÉ (REQUIS) -# ════════════════════════════════════════════════════════════ - -# Clé Bearer pour authentifier les appels à l'API locale du moteur. -# Toutes les routes /api/* sont protégées par cette clé. -# Générer une valeur forte : python -c "import secrets; print(secrets.token_urlsafe(32))" -MOTEUR_API_KEY=changez-moi - -# Origines CORS autorisées (séparées par des virgules). -# Laisser vide pour autoriser toutes les origines (mode développement uniquement). -# Ex. : http://localhost:8000,http://192.168.1.100:8000 +# ── Sécurité API (REQUIS — auth appliquée depuis P0-1.1) ── +# Clé Bearer exigée par TOUTES les routes /api sensibles (Depends(require_auth)), +# montées avec auth fail-closed. Seuls les webhooks WhatsApp et les assets +# statiques restent publics. Tant que cette clé n'est pas définie, les routes +# protégées renvoient 503 (jamais d'accès libre). +# Générer une valeur forte, ex. : python -c "import secrets;print(secrets.token_urlsafe(32))" +MOTEUR_API_KEY= + +# [T126] Chiffrement au repos de google_token.json (refresh_token OAuth Google +# permanent). Générer : python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# Si absente, le token est écrit/lu en clair (comportement historique, dégradation +# gracieuse — voir tools/encrypt_google_token.py pour migrer un fichier existant). +MCP_TOKEN_KEY= + +# [Audit 2026-07-09, #T206] Racine autorisée pour les outils write_file/read_file +# (tools/system.py). Par défaut : dossier PARENT de moteur_agents/ (ex. H:\AuxFilsDesIdees +# en dev, parent du dossier de déploiement en prod). Ne définir que si cette topologie +# ne correspond pas à votre déploiement (ex. Deck avec une racine de travail différente). +MOTEUR_WORKSPACE_ROOT= + +# Origines CORS autorisées (séparées par des virgules). Si vide → CORS ouvert (*) +# SANS credentials. Renseigner pour activer les credentials de façon sûre. +# Ex. : http://${ENGINE_HOST:-192.168.1.x}:8000,http://localhost:8000,http://localhost:5173 MOTEUR_CORS_ORIGINS= -# ════════════════════════════════════════════════════════════ -# SECTION 2 — PROVIDERS LLM -# (Chaque clé active le provider dans la cascade de routage) -# ════════════════════════════════════════════════════════════ +# ── Plugins & Tool-Maker (exécution de code généré — fail-closed) ── +# Le chargement du code des plugins (plugins/*/agent.py) exécute du code. +# Désactivé par défaut. Mettre à 1 UNIQUEMENT si les plugins sont de confiance. +MOTEUR_ENABLE_PLUGINS=0 +# Le ToolMakerAgent écrit du code généré par le LLM dans plugins/auto_generated/. +# Désactivé par défaut : tant que ce flag n'est pas à 1, les outils sont générés +# + validés en sandbox mais JAMAIS persistés. 1 pour autoriser l'auto-écriture. +MOTEUR_ENABLE_TOOL_MAKER=0 -# ── Google Gemini (RECOMMANDÉ POUR DÉMARRER — GRATUIT) ── -# Obtenir une clé gratuite sur : https://aistudio.google.com/apikey -# Jusqu'à 5 clés pour la rotation automatique (contournement des quotas). -# Sert aussi aux embeddings RAG (gemini-embedding-004). +# ── Providers LLM (chaque clé active le provider dans la cascade Elo) ── +# Google Gemini — Free Tier (AI Studio). Jusqu'à 6 clés pour la rotation KeyPool +# (contournement des quotas). Sert AUSSI aux embeddings RAG (gemini-embedding). GEMINI_API_KEY= GEMINI_API_KEY_2= GEMINI_API_KEY_3= GEMINI_API_KEY_4= GEMINI_API_KEY_5= - -# Modèle Gemini par défaut (optionnel, ex: gemini-2.5-flash, gemini-2.0-flash) -GEMINI_MODEL=gemini-2.0-flash - -# ── Google Gemini Payant (GCP/Vertex — optionnel) ── -# Débloque Search Grounding et quotas plus élevés. +GEMINI_API_KEY_6= +# Modèle Gemini par défaut (optionnel, ex: gemini-2.5-flash). +GEMINI_MODEL= +# Google Gemini — plan PAYANT (GCP/Vertex). Débloque Search Grounding + quotas élevés. GEMINI_PAYANT_API_KEY= +# Projet GCP ciblé (ex: projects/123456789). GEMINI_PROJECT_NAME= -# ── GitHub Models (GRATUIT avec un compte GitHub) ── -# Accès à GPT-4o, Llama 3.3 70B, etc. via : https://github.com/marketplace/models -GITHUB_TOKEN= - -# ── DeepSeek (très bon rapport qualité/prix) ── -# https://platform.deepseek.com/ +# Autres providers (tous optionnels). DEEPSEEK_API_KEY= - -# ── Anthropic Claude ── -# https://console.anthropic.com/ ANTHROPIC_API_KEY= - -# ── Autres providers (tous optionnels) ── MISTRAL_API_KEY= COHERE_API_KEY= CEREBRAS_API_KEY= +CEREBRAS_PAYANT_API_KEY= OPENROUTER_API_KEY= XAI_API_KEY= MINIMAX_API_KEY= DEEPINFRA_API_KEY= +GITHUB_TOKEN= ZHIPU_API_KEY= - -# ════════════════════════════════════════════════════════════ -# SECTION 3 — HOME ASSISTANT (optionnel) -# ════════════════════════════════════════════════════════════ - -# URL de votre instance Home Assistant -HASS_URL=http://192.168.1.x:8123 - -# Token longue durée HA -# Profil HA → Sécurité → Jetons d'accès longue durée +# Alibaba Cloud Model Studio / DashScope (Coding Plan = préfixe sk-sp-) +# Endpoint Coding Plan (défaut intl). CN: https://coding.dashscope.aliyuncs.com/v1 +# DASHSCOPE_BASE_URL=https://coding-intl.dashscope.aliyuncs.com/v1 +DASHSCOPE_API_KEY= + +# ── Sandbox distante ToolMaker (#T207) ── +# Fine-grained PAT dédié, limité à CE dépôt uniquement (Contents: read/write, +# Actions: read). NE PAS réutiliser GITHUB_TOKEN (clé LLM GitHub Models) ni un +# PAT existant plus large. Sans ce PAT, le ToolMaker rejette tout outil +# candidat (fail-closed, aucun repli vers une exécution locale). +MOTEUR_TOOLMAKER_PAT= +# Dépôt cible des branches éphémères toolmaker/validate-* (défaut ci-dessous). +MOTEUR_TOOLMAKER_REPO=Axellum/moteur_agents +# Délai max d'attente du verdict du runner (secondes, défaut 300). +MOTEUR_TOOLMAKER_TIMEOUT_S=300 + +# ── Home Assistant ── +# URL de l'instance HA (HASS_URL et HA_URL sont tous deux reconnus). +HASS_URL=http://${HA_HOST:-192.168.1.x}:8123 +# Token longue durée HA (profil HA → Jetons d'accès). Requis pour la domotique. HASS_TOKEN= - -# Vérification TLS (true par défaut, mettre false si certificat auto-signé) +# ── TLS Home Assistant (P0-1.5) ── +# Vérification TLS sécurisée par défaut (true). HA en HTTPS auto-signé : définir +# soit HA_CA_BUNDLE (préféré, .pem/.crt du certif), soit HA_VERIFY_TLS=false. HA_VERIFY_TLS=true -# Chemin vers le certificat CA si HTTPS auto-signé (préféré à HA_VERIFY_TLS=false) # HA_CA_BUNDLE=/chemin/vers/ha_ca.pem +# Nom d'hôte à vérifier dans le certificat, quand HASS_URL vise une IP que le +# certificat ne couvre pas (certificat émis pour un nom DNS seulement). On joint +# alors l'IP locale — rapide, sans dépendance WAN — en validant chaîne ET nom. +# Doit correspondre au certificat réellement servi, sinon la connexion est rejetée. +# HA_TLS_SERVER_HOSTNAME=mon-domaine.example.fr -# ════════════════════════════════════════════════════════════ -# SECTION 4 — FONCTIONNALITÉS AVANCÉES (optionnel) -# ════════════════════════════════════════════════════════════ - -# ── Plugins et Tool-Maker (DÉSACTIVÉS par défaut — sécurité) ── -# Les plugins exécutent du code Python — n'activer que si les sources sont de confiance. -MOTEUR_ENABLE_PLUGINS=0 -# Le ToolMakerAgent génère des outils Python automatiquement. -# Activer à 1 pour autoriser la persistance du code généré dans plugins/auto_generated/ -MOTEUR_ENABLE_TOOL_MAKER=0 - -# ── WhatsApp (Meta Cloud API ou Twilio — optionnel) ── -WHATSAPP_WEBHOOK_VERIFY_TOKEN= -WHATSAPP_USER_PHONE_NUMBER= -META_WHATSAPP_ACCESS_TOKEN= -META_WHATSAPP_PHONE_NUMBER_ID= -TWILIO_ACCOUNT_SID= -TWILIO_AUTH_TOKEN= -TWILIO_PHONE_NUMBER= - -# ── Observabilité Langfuse (optionnel) ── -# https://langfuse.com/ — tracing des appels LLM +# ── Observabilité (Langfuse — optionnel) ── LANGFUSE_PUBLIC_KEY= LANGFUSE_SECRET_KEY= -LANGFUSE_HOST=https://cloud.langfuse.com +LANGFUSE_HOST= # ── Divers ── -# Taux de conversion EUR→USD pour l'affichage des coûts (ex: 1.08) -EUR_USD_RATE=1.08 +# Taux de conversion EUR→USD pour l'affichage des coûts GCP (ex: 1.08). +EUR_USD_RATE= diff --git a/agents/dreamer_agent.py b/agents/dreamer_agent.py index 5f0cc49..2056c3e 100644 --- a/agents/dreamer_agent.py +++ b/agents/dreamer_agent.py @@ -16,13 +16,13 @@ Créé le : 2026-05-30 """ -import os -import json -import time import asyncio +import json import logging +import os +import time from datetime import datetime, timedelta -from typing import Dict, Any, Optional, List +from typing import Any logger = logging.getLogger("dreamer_agent") @@ -33,11 +33,31 @@ _REPORTS_DIR = os.path.join(_ENGINE_ROOT, "checkpoints", "dreamer_reports") +def _dreamcoder_repo_root(pa_config: dict | None) -> str: + """ + [#T202] Racine du dépôt de TRAVAIL DreamCoder (branches task/*, commits). + + Sur le Deck, la prod est déployée par overlay tar SANS dépôt Git légitime + (décision Axel : jamais de `git init` dans le dossier de prod) : DreamCoder + doit travailler dans un CLONE dédié (ex: /home/deck/dev_station/ + moteur_agents_dreamcoder/), configuré via + `persistent_agents.dreamcoder_repo_path` dans config.json. + + Sans configuration explicite, repli sur _ENGINE_ROOT (poste de dev Windows, + où le dossier du moteur EST un vrai clone) — comportement historique. + Les rapports/checkpoints restent TOUJOURS dans _ENGINE_ROOT (runtime moteur). + """ + configured = (pa_config or {}).get("dreamcoder_repo_path") or "" + if configured: + return os.path.abspath(configured) + return _ENGINE_ROOT + + # ────────────────────────────────────────────────────────────────── # État global du dreamer (exposé via l'API) # ────────────────────────────────────────────────────────────────── -dreamer_state: Dict[str, Any] = { +dreamer_state: dict[str, Any] = { "running": False, "enabled": False, "last_run_at": None, @@ -51,7 +71,7 @@ } -def _load_persistent_config() -> Dict[str, Any]: +def _load_persistent_config() -> dict[str, Any]: """Charge la section persistent_agents depuis config.json.""" try: from core.llm_gateway import load_config @@ -66,26 +86,26 @@ def _load_persistent_config() -> Dict[str, Any]: # Extraction des données de la journée # ────────────────────────────────────────────────────────────────── -def _extract_daily_sessions() -> Dict[str, Any]: +def _extract_daily_sessions() -> dict[str, Any]: """ Extrait les sessions et appels LLM des dernières 24h depuis session_history.db. Retourne un résumé structuré pour le prompt du dreamer. """ try: from core.session_history import get_sessions, get_token_stats - + # Récupérer les sessions des dernières 24h sessions = get_sessions(limit=50) cutoff = time.time() - 86400 # 24h - + recent_sessions = [ s for s in sessions if s.get("started_at", 0) >= cutoff ] - + # Statistiques de tokens des dernières 24h token_stats = get_token_stats(since_hours=24) - + # Extraire les erreurs et corrections errors = [] corrections = [] @@ -100,7 +120,7 @@ def _extract_daily_sessions() -> Dict[str, Any]: "objective": session.get("objective", "")[:100], "result": session.get("result_summary", "")[:200], }) - + return { "date": datetime.now().strftime("%Y-%m-%d"), "sessions_count": len(recent_sessions), @@ -116,14 +136,14 @@ def _extract_daily_sessions() -> Dict[str, Any]: return {"error": str(e), "sessions_count": 0} -def _extract_existing_lessons() -> List[Dict]: +def _extract_existing_lessons() -> list[dict]: """ Récupère les leçons existantes dans memory.db pour détecter les contradictions. """ try: from memory.memory_db import MemoryDB db = MemoryDB.get_instance() - + # Récupérer les faits de chaque catégorie counts = db.get_all_facts_count() all_facts = [] @@ -136,13 +156,13 @@ def _extract_existing_lessons() -> List[Dict]: "content": f.get("content", "")[:150], "score": f.get("relevance_score", 1.0), }) - + return all_facts except Exception as e: logger.warning(f"[DREAMER] Erreur de lecture des leçons existantes: {e}") return [] -async def _consolidate_memory() -> Dict[str, int]: +async def _consolidate_memory() -> dict[str, int]: """ Exécute la consolidation mémoire en appelant les méthodes existantes. Réutilise exactement le même code que engine.py::_consolidate_memory(). @@ -151,32 +171,32 @@ async def _consolidate_memory() -> Dict[str, int]: Retourne un dict avec les compteurs d'actions effectuées. """ actions = {"decayed": 0, "gc_summarized": 0, "gc_archived": 0, "lessons_added": 0} - + try: from memory.memory_db import MemoryDB db = MemoryDB.get_instance() - + logger.info("[DREAMER] [Consolidation BDD] 1. Lancement du decay de pertinence...") # 1. Decay des scores de pertinence (faits non consultés > 7 jours) actions["decayed"] = await asyncio.to_thread(db.decay_relevance, 0.03) if actions["decayed"] > 0: logger.info(f"[DREAMER] [Consolidation BDD] Decay appliqué sur {actions['decayed']} faits") - + logger.info("[DREAMER] [Consolidation BDD] 2. Lancement du GC du graphe...") # 2. GC du graphe (observations > 15, entités temporaires > 30 jours) gc_result = await asyncio.to_thread(db.gc_graph_entities, 15, 30) actions["gc_summarized"] = gc_result.get("summarized", 0) actions["gc_archived"] = gc_result.get("archived", 0) - + if actions["gc_summarized"] > 0 or actions["gc_archived"] > 0: logger.info( f"[DREAMER] [Consolidation BDD] GC graphe: {actions['gc_summarized']} résumées, " f"{actions['gc_archived']} archivées" ) - + except Exception as e: logger.error(f"[DREAMER] Erreur de consolidation mémoire: {e}", exc_info=True) - + return actions @@ -196,7 +216,7 @@ async def _compress_old_episodes() -> dict: """ stats = {"compressed": 0, "purged": 0, "kept": 0, "error": None} try: - from memory.episodes import EpisodeStore, EPISODE_TTL_DAYS + from memory.episodes import EPISODE_TTL_DAYS, EpisodeStore from memory.memory_db import MemoryDB episode_store = EpisodeStore() @@ -299,8 +319,8 @@ def _find_episode_file(episodes_dir: str, session_id: str) -> str: return "" -async def _analyze_with_llm(daily_data: Dict, existing_lessons: List[Dict], - pa_config: Dict) -> Optional[Dict]: +async def _analyze_with_llm(daily_data: dict, existing_lessons: list[dict], + pa_config: dict) -> dict | None: """ Envoie un prompt structuré au LLM pour analyser la journée. @@ -313,14 +333,14 @@ async def _analyze_with_llm(daily_data: Dict, existing_lessons: List[Dict], if daily_data.get("sessions_count", 0) == 0: logger.info("[DREAMER] [Analyse LLM] Aucune session à analyser — LLM non appelé") return None - + try: from core.llm_gateway import LLMGateway, load_config - + gateway = LLMGateway() config = load_config() dreamer_tier = pa_config.get("dreamer_model", "leger") - + # Construire le prompt d'analyse system_prompt = """Tu es un agent de consolidation mémoire pour un système multi-agents domotique. Tu analyses les sessions de la journée écoulée et extrais les leçons apprises. @@ -364,7 +384,7 @@ async def _analyze_with_llm(daily_data: Dict, existing_lessons: List[Dict], # Résoudre le provider pour le tier du dreamer model_name, provider = gateway.get_provider_for_tier(dreamer_tier, config) logger.info(f"[DREAMER] [Analyse LLM] Modèle résolu pour consolidation : {model_name} (provider: {type(provider).__name__})") - + logger.info("[DREAMER] [Analyse LLM] Envoi de la requête au LLM (generate_async)...") response = await provider.generate_async( system_prompt=system_prompt, @@ -373,17 +393,17 @@ async def _analyze_with_llm(daily_data: Dict, existing_lessons: List[Dict], temperature=0.3, ) logger.info("[DREAMER] [Analyse LLM] Réponse reçue du LLM.") - + # Parser la réponse JSON response_text = response.strip() # Nettoyer les balises markdown ```json ... ``` si présentes if response_text.startswith("```"): lines = response_text.split("\n") response_text = "\n".join(lines[1:-1]) - + analysis = json.loads(response_text) return analysis - + except json.JSONDecodeError as je: logger.warning(f"[DREAMER] [Analyse LLM] Réponse LLM non-JSON: {je}. Réponse brute: {response[:200]}...") return None @@ -392,17 +412,17 @@ async def _analyze_with_llm(daily_data: Dict, existing_lessons: List[Dict], return None -def _apply_analysis(analysis: Dict) -> Dict[str, int]: +def _apply_analysis(analysis: dict) -> dict[str, int]: """ Applique les résultats de l'analyse LLM dans memory.db. Fonction synchrone exécutée dans un thread pour SQLite. """ applied = {"lessons_added": 0, "facts_marked_obsolete": 0} - + try: from memory.memory_db import MemoryDB db = MemoryDB.get_instance() - + # 1. Ajouter les nouvelles leçons for lesson in analysis.get("new_lessons", []): try: @@ -417,7 +437,7 @@ def _apply_analysis(analysis: Dict) -> Dict[str, int]: applied["lessons_added"] += 1 except Exception as le: logger.warning(f"[DREAMER] Erreur d'ajout de leçon: {le}") - + # 2. Marquer les faits obsolètes (réduire leur score de pertinence) for obsolete in analysis.get("obsolete_facts", []): title = obsolete.get("title", "") @@ -441,25 +461,25 @@ def _apply_analysis(analysis: Dict) -> Dict[str, int]: conn.close() except Exception as oe: logger.warning(f"[DREAMER] Erreur de marquage obsolète: {oe}") - + if applied["lessons_added"] > 0: logger.info(f"[DREAMER] [Appliquer Analyse] ✅ {applied['lessons_added']} leçon(s) ajoutée(s)") if applied["facts_marked_obsolete"] > 0: logger.info(f"[DREAMER] [Appliquer Analyse] 🗑️ {applied['facts_marked_obsolete']} fait(s) marqué(s) obsolètes") - + except Exception as e: logger.error(f"[DREAMER] Erreur d'application de l'analyse: {e}", exc_info=True) - + return applied -def _save_report(report: Dict) -> str: +def _save_report(report: dict) -> str: """Sauvegarde le rapport de consolidation dans un fichier JSON.""" os.makedirs(_REPORTS_DIR, exist_ok=True) - + filename = f"dreamer_report_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.json" filepath = os.path.join(_REPORTS_DIR, filename) - + try: with open(filepath, 'w', encoding='utf-8') as f: json.dump(report, f, indent=2, ensure_ascii=False) @@ -470,11 +490,373 @@ def _save_report(report: Dict) -> str: return "" +# ────────────────────────────────────────────────────────────────── +# DreamCoder — traitement d'une tâche + boucle de drainage à durée réglable +# ────────────────────────────────────────────────────────────────── + +async def _process_one_dreamcoder_task(task: dict, pa_config: dict, provider: str, + orig_branch: str, bg: Any) -> dict[str, Any]: + """ + Traite UNE tâche du backlog : prépare une branche Git dédiée, exécute le + pipeline complet dessus, commit si succès ou rollback+retry sinon, met à + jour le statut de la tâche. Logique reprise à l'identique de l'ancien + bloc inline de run_dreamer_cycle (étape 3.0) — extraite ici pour être + appelable en boucle par _run_dreamcoder_drain_loop(). + + :return: dict {"task_id", "title", "status", "branch", "cost_usd", "tokens_used", "error"} + """ + import filelock + + from core.backlog_db import update_task_status + + task_id = task["id"] + result: dict[str, Any] = { + "task_id": task_id, "title": task["title"], "status": None, + "branch": None, "cost_usd": 0.0, "tokens_used": 0, "error": None, + } + logger.info(f"[DREAMER] [DreamCoder] Tâche trouvée : ID {task_id} - '{task['title']}'") + logger.info(f"[DREAMER] [DreamCoder] Provider alloué : {provider}") + + # Configurer temporairement les modèles selon le provider alloué. + # 07/07 : rôles séparés — le provider de la cascade (gratuit/abonnement en + # priorité) fait l'EXÉCUTION brute (dégrossir), tandis que le PLANNER et le + # REVIEWER sont pilotés par un "gouverneur" fixe (DeepSeek par défaut, + # escaladé vers zai-glm-4.7 — GLM/Z.ai hébergé gratuitement via la clé + # Cerebras — sur une tâche déjà retentée au moins une fois, décision Axel + # 07/07 : "gratuit pour dégrossir, DeepSeek/Z.ai pour gouverner"). Le + # gouverneur ne passe PAS par la cascade BudgetGuard (appel direct par nom + # de modèle) donc il est protégé séparément par le plafond quotidien + # combiné (total_daily_budget_usd) : au-delà, on dégrade sur le modèle + # gratuit de l'exécuteur plutôt que de continuer à consommer le budget + # payant du gouverneur. + from core.llm_gateway import load_config + config = load_config() + temp_config = config.copy() + + executor_model_map = { + "ollama": "ollama_local", + "lmstudio": "local", + "gemini-cli-abo": "gemini-3.5-flash-high-cli", + "claude-cli-abo": "claude-sonnet-4.6-thinking-cli", + "cerebras-free": "gpt-oss-120b", + "dashscope-coding": "dashscope/qwen3-coder-next", + "cohere-free": "command-r-plus-08-2024", + "mistral-free": "open-mistral-nemo", + "gemini-free": "gemini-3.5-flash-free", + "deepseek-free": "deepseek-chat", + "anthropic-claude-haiku": "claude-haiku-4-5", + } + executor_model = executor_model_map.get(provider, "deepseek-chat") + temp_config["executor_model"] = executor_model + + dreamcoder_spent_today = await bg.get_scoped_spend_usd("dreamcoder", 86400.0) + total_cap = bg.config.get("total_daily_budget_usd", 1.0) + governor_within_budget = dreamcoder_spent_today < total_cap + + if not governor_within_budget: + logger.warning( + f"[DREAMER] [DreamCoder] Plafond quotidien combiné atteint " + f"(${dreamcoder_spent_today:.4f}/{total_cap} USD) — gouverneur dégradé " + f"sur le modèle de l'exécuteur ('{executor_model}') pour cette tâche." + ) + governor_model = executor_model + elif task.get("retries", 0) > 0: + # Escalade qualité : la tâche a déjà échoué au moins une fois, on monte + # en gamme sur un modèle GLM (gratuit via Cerebras) plutôt que de + # retenter avec le même gouverneur. + governor_model = "zai-glm-4.7" + else: + governor_model = "deepseek-chat" + + temp_config["planner_model"] = governor_model + temp_config["reviewer_model"] = governor_model + + # Préparer la branche éphémère de l'agent + from tools.git_safety import ( + _run_git, + git_generate_semantic_commit_msg, + git_prepare_agent_branch, + git_rollback_checkpoint, + ) + + # [#T202] Toutes les opérations Git de la tâche ciblent le dépôt de travail + # DreamCoder (clone dédié si configuré, _ENGINE_ROOT sinon). + repo_root = _dreamcoder_repo_root(pa_config) + + branch_name = await asyncio.to_thread( + git_prepare_agent_branch, + session_id=str(task_id), + repo_path=repo_root, + prefix="task/" + ) + + if branch_name.startswith("Erreur"): + logger.error(f"[DREAMER] [DreamCoder] Impossible de préparer la branche Git : {branch_name}") + await update_task_status(task_id, 'failed', error_message=branch_name) + result["status"] = "failed" + result["error"] = branch_name + return result + + logger.info(f"[DREAMER] [DreamCoder] Branche Git créée : {branch_name}") + await update_task_status(task_id, 'running', git_branch=branch_name) + result["branch"] = branch_name + + from core.app_state import get_app_state as _get_app_state + from services.pipeline_service import run_full_pipeline + + # [#T202] Propager la racine de travail au pipeline (hooks Git/YAML/doc de + # l'Engine) et l'imposer dans le prompt : read_file/write_file résolvent les + # chemins relatifs contre le CWD du process serveur, pas contre le clone. + temp_config["repo_root"] = repo_root + work_prompt = ( + f"[DÉPÔT DE TRAVAIL : {repo_root}]\n" + f"Règle stricte : utilise EXCLUSIVEMENT des chemins ABSOLUS sous cette " + f"racine pour read_file/write_file/run_terminal_command.\n\n" + f"{task['description']}" + ) + + router = _get_app_state().get_shared_router() + initial_payload, starting_agent = await router.analyze_request(task["description"]) + + async def sse_callback(event_type, data, engine_inst): + pass + + session_id = f"task_{task_id}" + + try: + logger.info("[DREAMER] [DreamCoder] Démarrage du pipeline de tâche...") + pipeline_result = await asyncio.wait_for( + run_full_pipeline( + user_prompt=work_prompt, + session_id=session_id, + initial_payload=initial_payload, + starting_agent=starting_agent, + on_event_callback=sse_callback, + config=temp_config + ), + timeout=600.0 # 10 minutes timeout + ) + + logger.info(f"[DREAMER] [DreamCoder] Pipeline terminé avec statut: {pipeline_result.get('status')}") + + # Collecter les jetons utilisés + from core.token_tracker import get_session_total_tokens + tokens_used = get_session_total_tokens(session_id) + + # Déterminer le coût + from core.runtime_db import get_connection + cost = 0.0 + try: + with get_connection() as conn: + row = conn.execute( + "SELECT COALESCE(SUM(cost_usd), 0.0) FROM token_usage WHERE session_id = ?", + (session_id,) + ).fetchone() + if row: + cost = row[0] + except Exception as _ce: + logger.warning(f"[DREAMER] [DreamCoder] Impossible de lire le coût réel : {_ce}") + + # Enregistrer la consommation + window_type = "hourly" if provider == "gemini-free" else "daily" + await bg.record_usage( + provider=provider, + tokens=tokens_used, + cost=cost, + model=temp_config["planner_model"], + window_type=window_type + ) + result["tokens_used"] = tokens_used + result["cost_usd"] = cost + + if pipeline_result.get("status") == "completed" or pipeline_result.get("status") == "success": + logger.info("[DREAMER] [DreamCoder] Tâche réussie. Enregistrement du commit...") + await asyncio.to_thread(_run_git, ["add", "-A"], repo_root) + commit_msg = await asyncio.to_thread(git_generate_semantic_commit_msg, repo_root, session_id) + await asyncio.to_thread(_run_git, ["commit", "-m", commit_msg], repo_root) + + # Générer le diff + _, diff_out, _ = await asyncio.to_thread(_run_git, ["diff", "HEAD~1..HEAD"], repo_root) + + # Créer le fichier de rapport de résultats + results_dir = os.path.join(_ENGINE_ROOT, "checkpoints", "dreamcoder_results") + os.makedirs(results_dir, exist_ok=True) + result_file = os.path.join(results_dir, f"task_{task_id}.json") + + # Écriture sous verrou + lock = filelock.FileLock(result_file + ".lock") + with lock: + with open(result_file, "w", encoding="utf-8") as f: + json.dump({ + "task_id": task_id, + "title": task["title"], + "branch": branch_name, + "diff": diff_out, + "summary": pipeline_result.get("response", ""), + "timestamp": time.time(), + "tokens_used": tokens_used, + "cost_usd": cost + }, f, indent=2, ensure_ascii=False) + + # Mettre à jour la base + await update_task_status( + task_id, + 'completed', + result_summary=pipeline_result.get("response", "Succès sans résumé"), + tokens_used=tokens_used + ) + result["status"] = "success" + else: + err_msg = pipeline_result.get("error") or "Le pipeline s'est terminé sur un échec." + raise RuntimeError(err_msg) + + except Exception as pipeline_err: + logger.error(f"[DREAMER] [DreamCoder] Échec de l'exécution : {pipeline_err}") + + # Cooldown du provider d'exécution (voir core/budget_guard.py::mark_provider_failed) — + # évite qu'un provider cassé (ex: CLI Gemini/Claude non trustée) soit + # re-sélectionné sur la tâche suivante du même cycle de drainage. + from core.budget_guard import mark_provider_failed + mark_provider_failed(provider) + + # Rollback git + await asyncio.to_thread(git_rollback_checkpoint, repo_root) + + # Supprimer la branche + await asyncio.to_thread(_run_git, ["checkout", orig_branch], repo_root) + await asyncio.to_thread(_run_git, ["branch", "-D", branch_name], repo_root) + + # Incrémenter les tentatives + next_retries = task.get("retries", 0) + 1 + status = "failed" + if next_retries >= 3: + status = "abandoned" + logger.warning(f"[DREAMER] [DreamCoder] Tâche {task_id} abandonnée après 3 échecs.") + + await update_task_status( + task_id, + status, + error_message=str(pipeline_err), + retries=next_retries + ) + result["status"] = status + result["error"] = str(pipeline_err) + + finally: + # Retourner sur la branche d'origine + await asyncio.to_thread(_run_git, ["checkout", orig_branch], repo_root) + + return result + + +async def _run_dreamcoder_drain_loop(pa_config: dict) -> dict[str, Any]: + """ + Boucle bornée en durée (dreamcoder_max_cycle_minutes) qui draine le + backlog tant qu'il reste du temps, du budget, et des tâches 'pending'. + Remplace l'ancien traitement d'une tâche unique par cycle. Ne contourne + JAMAIS la porte d'approbation Git humaine (PUT /api/backlog/tasks/{id}) : + chaque tâche réussie reste sur sa branche 'task/*' en attente de revue. + """ + from core.backlog_db import get_next_task, update_task_status + from core.budget_guard import BudgetGuard + from tools.git_safety import _run_git + + max_minutes = pa_config.get("dreamcoder_max_cycle_minutes", 12) + deadline = time.time() + max_minutes * 60 + bg = BudgetGuard() + + results: dict[str, Any] = { + "processed": 0, "success": 0, "failed": 0, "abandoned": 0, "paused": 0, + "total_cost_usd": 0.0, "total_tokens": 0, + "branches_awaiting_review": [], "tasks": [], "stopped_reason": None, + } + + # [#T202] Dépôt de travail du cycle (clone dédié si configuré). + repo_root = _dreamcoder_repo_root(pa_config) + + # [#T202] Rafraîchir le clone AVANT le cycle — UNIQUEMENT quand un clone + # dédié est explicitement configuré : jamais de pull automatique sur le + # dépôt de dev (_ENGINE_ROOT) sous les pieds d'Axel. --ff-only : un clone + # divergent (branches task/* locales non mergées) n'est jamais écrasé, + # le pull échoue proprement et le cycle continue sur l'état local. + if (pa_config or {}).get("dreamcoder_repo_path"): + pull_code, pull_out, pull_err = await asyncio.to_thread( + _run_git, ["pull", "--ff-only"], repo_root) + if pull_code == 0: + logger.info(f"[DREAMER] [DreamCoder] Clone rafraîchi ({repo_root}) : {pull_out.strip()[:120]}") + else: + logger.warning( + f"[DREAMER] [DreamCoder] pull --ff-only impossible sur {repo_root} " + f"({(pull_err or pull_out).strip()[:160]}) — cycle sur l'état local." + ) + + code, orig_branch, _ = await asyncio.to_thread( + _run_git, ["rev-parse", "--abbrev-ref", "HEAD"], repo_root) + orig_branch = orig_branch.strip() if (code == 0 and orig_branch) else "master" + logger.info(f"[DREAMER] [DreamCoder] Branche d'origine : {orig_branch}") + + while True: + if time.time() >= deadline: + results["stopped_reason"] = "max_cycle_minutes_reached" + break + + task = await get_next_task() + if not task: + results["stopped_reason"] = "backlog_empty" + break + + # Budget vérifié À CHAQUE tâche (pas une fois en tête de cycle) + provider = await bg.get_available_provider() + if not provider: + logger.warning("[DREAMER] [DreamCoder] Aucun provider disponible (quotas épuisés).") + await update_task_status(task["id"], 'paused', error_message='quota_exhausted') + results["paused"] += 1 + results["stopped_reason"] = "budget_exhausted" + break + + task_result = await _process_one_dreamcoder_task(task, pa_config, provider, orig_branch, bg) + results["processed"] += 1 + results["tasks"].append(task_result) + results["total_cost_usd"] += task_result.get("cost_usd", 0.0) + results["total_tokens"] += task_result.get("tokens_used", 0) + status = task_result.get("status") + if status == "success": + results["success"] += 1 + if task_result.get("branch"): + results["branches_awaiting_review"].append(task_result["branch"]) + elif status in ("failed", "abandoned", "paused"): + results[status] += 1 + # Les tâches 'failed'/'abandoned' sortent naturellement de 'pending' : + # get_next_task() ne les re-sélectionnera pas au tour suivant, donc + # "continuer au-delà d'un abandon" ne demande aucun code spécial. + + return results + + +def _save_dreamcoder_cycle_report(results: dict) -> str: + """ + Rapport agrégé de fin de cycle (tâches traitées/réussies/échouées/ + abandonnées, coût total, branches en attente de revue). Vient EN PLUS + des fichiers task_{id}.json déjà écrits par tâche + (checkpoints/dreamcoder_results/), ne les remplace pas. + """ + results_dir = os.path.join(_ENGINE_ROOT, "checkpoints", "dreamcoder_results") + os.makedirs(results_dir, exist_ok=True) + filepath = os.path.join(results_dir, f"cycle_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.json") + try: + with open(filepath, "w", encoding="utf-8") as f: + json.dump(results, f, indent=2, ensure_ascii=False) + return filepath + except Exception as e: + logger.error(f"[DREAMER] Erreur sauvegarde rapport de cycle DreamCoder : {e}") + return "" + + # ────────────────────────────────────────────────────────────────── # Pipeline principal du dreamer # ────────────────────────────────────────────────────────────────── -async def run_dreamer_cycle(pa_config: Dict) -> Dict[str, Any]: +async def run_dreamer_cycle(pa_config: dict) -> dict[str, Any]: """ Exécute un cycle complet de consolidation mémoire. @@ -486,17 +868,17 @@ async def run_dreamer_cycle(pa_config: Dict) -> Dict[str, Any]: 5. Génération du rapport (Thread) """ cycle_start = time.time() - + logger.info("[DREAMER] 🌙 Démarrage du cycle de consolidation mémoire") dreamer_state["running"] = True - + report = { "timestamp": datetime.now().isoformat(), "pipeline": {}, "actions": {}, "duration_ms": 0, } - + try: anomalies = [] # 1. Extraction (dans un thread) @@ -507,7 +889,7 @@ async def run_dreamer_cycle(pa_config: Dict) -> Dict[str, Any]: "error_count": daily_data.get("error_count", 0), } logger.info(f"[DREAMER] [Etape 1/5] Sessions extraites : {daily_data.get('sessions_count', 0)}") - + # 2. Consolidation automatique (decay, GC) logger.info("[DREAMER] [Etape 2/5] Lancement de la consolidation SQLite (decay/GC)...") consolidation = await _consolidate_memory() @@ -568,8 +950,9 @@ async def run_dreamer_cycle(pa_config: Dict) -> Dict[str, Any]: # 2.8. Migration ChromaDB (mémoire vectorielle persistante) logger.info("[DREAMER] [Etape 2.8] Migration vers ChromaDB (s'il est disponible)...") try: - from memory.chroma_memory import get_chroma_memory import os + + from memory.chroma_memory import get_chroma_memory _episodes_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "memory", "episodes" @@ -618,13 +1001,13 @@ async def run_dreamer_cycle(pa_config: Dict) -> Dict[str, Any]: # Vérifier si l'utilisateur est inactif from core.app_state import get_app_state app_state = get_app_state() - + user_active = False if app_state.execution_state.get("status") == "running": logger.info("[DREAMER] [DreamCoder] L'utilisateur est actif (moteur running). Etape sautée.") user_active = True report["actions"]["dreamcoder"]["reason"] = "user_active_running" - + if not user_active: min_idle = pa_config.get("dreamcoder_min_idle_hours", 1.0) last_user_activity = 0 @@ -638,225 +1021,36 @@ async def run_dreamer_cycle(pa_config: Dict) -> Dict[str, Any]: last_user_activity = row[0] except Exception as _ie: logger.warning(f"[DREAMER] [DreamCoder] Erreur lors de la lecture des sessions : {_ie}") - + idle_hours = (time.time() - last_user_activity) / 3600 if idle_hours < min_idle: logger.info(f"[DREAMER] [DreamCoder] L'utilisateur a été actif récemment ({idle_hours:.2f}h < {min_idle}h). Etape sautée.") user_active = True report["actions"]["dreamcoder"]["reason"] = "user_active_recent" report["actions"]["dreamcoder"]["idle_hours"] = round(idle_hours, 2) - + if not user_active: - from core.backlog_db import get_next_task, update_task_status - from core.budget_guard import BudgetGuard - import filelock - - task = await get_next_task() - if task: - task_id = task["id"] - logger.info(f"[DREAMER] [DreamCoder] Tâche trouvée : ID {task_id} - '{task['title']}'") - report["actions"]["dreamcoder"]["task_processed"] = True - report["actions"]["dreamcoder"]["task_id"] = task_id - report["actions"]["dreamcoder"]["task_title"] = task["title"] - - # Vérifier le budget/provider disponible - bg = BudgetGuard() - provider = await bg.get_available_provider() - if not provider: - logger.warning(f"[DREAMER] [DreamCoder] Aucun provider disponible (quotas épuisés).") - await update_task_status(task_id, 'paused', error_message='quota_exhausted') - report["actions"]["dreamcoder"]["status"] = "paused" - report["actions"]["dreamcoder"]["error"] = "quota_exhausted" - else: - logger.info(f"[DREAMER] [DreamCoder] Provider alloué : {provider}") - report["actions"]["dreamcoder"]["allocated_provider"] = provider - - # Configurer temporairement les modèles selon le provider alloué - from core.llm_gateway import load_config - config = load_config() - temp_config = config.copy() - - if provider == "ollama": - # Runtime local préféré (cf. budget_guard) — clé gateway ollama_local - temp_config["planner_model"] = "ollama_local" - temp_config["executor_model"] = "ollama_local" - temp_config["reviewer_model"] = "ollama_local" - elif provider == "lmstudio": - temp_config["planner_model"] = "local" - temp_config["executor_model"] = "local" - temp_config["reviewer_model"] = "local" - elif provider == "gemini-free": - temp_config["planner_model"] = "gemini-3.5-flash-free" - temp_config["executor_model"] = "gemini-3.5-flash-free" - temp_config["reviewer_model"] = "gemini-3.5-flash-free" - elif provider == "deepseek-free": - temp_config["planner_model"] = "deepseek-chat" - temp_config["executor_model"] = "deepseek-chat" - temp_config["reviewer_model"] = "deepseek-chat" - elif provider == "anthropic-claude-haiku": - temp_config["planner_model"] = "claude-haiku-4-5" - temp_config["executor_model"] = "claude-haiku-4-5" - temp_config["reviewer_model"] = "claude-haiku-4-5" - - # Préparer la branche éphémère de l'agent - from tools.git_safety import git_prepare_agent_branch, git_rollback_checkpoint, git_generate_semantic_commit_msg, _run_git - - # Récupérer la branche d'origine - code_branch, orig_branch, _ = await asyncio.to_thread(_run_git, ["rev-parse", "--abbrev-ref", "HEAD"], _ENGINE_ROOT) - orig_branch = orig_branch.strip() if (code_branch == 0 and orig_branch) else "master" - - logger.info(f"[DREAMER] [DreamCoder] Branche d'origine : {orig_branch}") - - branch_name = await asyncio.to_thread( - git_prepare_agent_branch, - session_id=str(task_id), - repo_path=_ENGINE_ROOT, - prefix="task/" - ) - - if branch_name.startswith("Erreur"): - logger.error(f"[DREAMER] [DreamCoder] Impossible de préparer la branche Git : {branch_name}") - await update_task_status(task_id, 'failed', error_message=branch_name) - report["actions"]["dreamcoder"]["status"] = "failed" - report["actions"]["dreamcoder"]["error"] = branch_name - else: - logger.info(f"[DREAMER] [DreamCoder] Branche Git créée : {branch_name}") - await update_task_status(task_id, 'running', git_branch=branch_name) - - # Lancer le pipeline complet - from services.pipeline_service import run_full_pipeline - from core.app_state import get_app_state as _get_app_state - - # [P1-2.1] Router canonique partagé (gateway/RAG/config câblés). - router = _get_app_state().get_shared_router() - initial_payload, starting_agent = await router.analyze_request(task["description"]) - - async def sse_callback(event_type, data, engine_inst): - pass - - session_id = f"task_{task_id}" - - try: - logger.info(f"[DREAMER] [DreamCoder] Démarrage du pipeline de tâche...") - result = await asyncio.wait_for( - run_full_pipeline( - user_prompt=task["description"], - session_id=session_id, - initial_payload=initial_payload, - starting_agent=starting_agent, - on_event_callback=sse_callback, - config=temp_config - ), - timeout=600.0 # 10 minutes timeout - ) - - logger.info(f"[DREAMER] [DreamCoder] Pipeline terminé avec statut: {result.get('status')}") - - # Collecter les jetons utilisés - from core.token_tracker import get_session_total_tokens - tokens_used = get_session_total_tokens(session_id) - - # Déterminer le coût - from core.runtime_db import get_connection - cost = 0.0 - try: - with get_connection() as conn: - row = conn.execute( - "SELECT COALESCE(SUM(cost_usd), 0.0) FROM token_usage WHERE session_id = ?", - (session_id,) - ).fetchone() - if row: - cost = row[0] - except Exception as _ce: - logger.warning(f"[DREAMER] [DreamCoder] Impossible de lire le coût réel : {_ce}") - - # Enregistrer la consommation - window_type = "hourly" if provider == "gemini-free" else "daily" - await bg.record_usage( - provider=provider, - tokens=tokens_used, - cost=cost, - model=temp_config["planner_model"], - window_type=window_type - ) - - if result.get("status") == "completed" or result.get("status") == "success": - logger.info(f"[DREAMER] [DreamCoder] Tâche réussie. Enregistrement du commit...") - await asyncio.to_thread(_run_git, ["add", "-A"], _ENGINE_ROOT) - commit_msg = await asyncio.to_thread(git_generate_semantic_commit_msg, _ENGINE_ROOT, session_id) - await asyncio.to_thread(_run_git, ["commit", "-m", commit_msg], _ENGINE_ROOT) - - # Générer le diff - _, diff_out, _ = await asyncio.to_thread(_run_git, ["diff", "HEAD~1..HEAD"], _ENGINE_ROOT) - - # Créer le fichier de rapport de résultats - results_dir = os.path.join(_ENGINE_ROOT, "checkpoints", "dreamcoder_results") - os.makedirs(results_dir, exist_ok=True) - result_file = os.path.join(results_dir, f"task_{task_id}.json") - - # Écriture sous verrou - lock = filelock.FileLock(result_file + ".lock") - with lock: - with open(result_file, "w", encoding="utf-8") as f: - json.dump({ - "task_id": task_id, - "title": task["title"], - "branch": branch_name, - "diff": diff_out, - "summary": result.get("response", ""), - "timestamp": time.time(), - "tokens_used": tokens_used, - "cost_usd": cost - }, f, indent=2, ensure_ascii=False) - - # Mettre à jour la base - await update_task_status( - task_id, - 'completed', - result_summary=result.get("response", "Succès sans résumé"), - tokens_used=tokens_used - ) - report["actions"]["dreamcoder"]["status"] = "success" - else: - err_msg = result.get("error") or "Le pipeline s'est terminé sur un échec." - raise RuntimeError(err_msg) - - except Exception as pipeline_err: - logger.error(f"[DREAMER] [DreamCoder] Échec de l'exécution : {pipeline_err}") - - # Rollback git - await asyncio.to_thread(git_rollback_checkpoint, _ENGINE_ROOT) - - # Supprimer la branche - await asyncio.to_thread(_run_git, ["checkout", orig_branch], _ENGINE_ROOT) - await asyncio.to_thread(_run_git, ["branch", "-D", branch_name], _ENGINE_ROOT) - - # Incrémenter les tentatives - next_retries = task.get("retries", 0) + 1 - status = "failed" - if next_retries >= 3: - status = "abandoned" - logger.warning(f"[DREAMER] [DreamCoder] Tâche {task_id} abandonnée après 3 échecs.") - - await update_task_status( - task_id, - status, - error_message=str(pipeline_err), - retries=next_retries - ) - report["actions"]["dreamcoder"]["status"] = status - report["actions"]["dreamcoder"]["error"] = str(pipeline_err) - - finally: - # Retourner sur la branche d'origine - await asyncio.to_thread(_run_git, ["checkout", orig_branch], _ENGINE_ROOT) - else: + drain_results = await _run_dreamcoder_drain_loop(pa_config) + report["actions"]["dreamcoder"] = { + "status": "drained", + "tasks_processed": drain_results["processed"], + "tasks_succeeded": drain_results["success"], + "tasks_failed": drain_results["failed"], + "tasks_abandoned": drain_results["abandoned"], + "tasks_paused": drain_results["paused"], + "total_cost_usd": round(drain_results["total_cost_usd"], 6), + "total_tokens": drain_results["total_tokens"], + "branches_awaiting_review": drain_results["branches_awaiting_review"], + "stopped_reason": drain_results["stopped_reason"], + } + if drain_results["processed"] > 0: + await asyncio.to_thread(_save_dreamcoder_cycle_report, drain_results) + elif drain_results["stopped_reason"] == "backlog_empty": logger.info("[DREAMER] [DreamCoder] Aucune tâche éligible dans le backlog.") - report["actions"]["dreamcoder"]["reason"] = "no_eligible_task" else: logger.info("[DREAMER] [DreamCoder] DreamCoder désactivé par configuration.") report["actions"]["dreamcoder"]["reason"] = "disabled" - + except Exception as dreamcoder_err: logger.error(f"[DREAMER] [DreamCoder] Erreur générale DreamCoder : {dreamcoder_err}", exc_info=True) report["actions"]["dreamcoder"]["error"] = str(dreamcoder_err) @@ -867,7 +1061,7 @@ async def sse_callback(event_type, data, engine_inst): existing_lessons = await asyncio.to_thread(_extract_existing_lessons) logger.info("[DREAMER] [Etape 3/5] Appel de l'analyse LLM...") analysis = await _analyze_with_llm(daily_data, existing_lessons, pa_config) - + if analysis: report["pipeline"]["llm_analysis"] = { "new_lessons": len(analysis.get("new_lessons", [])), @@ -875,7 +1069,7 @@ async def sse_callback(event_type, data, engine_inst): "contradictions": len(analysis.get("contradictions", [])), "summary": analysis.get("summary", ""), } - + # 4. Appliquer les résultats de l'analyse logger.info("[DREAMER] [Etape 4/5] Application des conclusions LLM dans memory.db...") applied = await asyncio.to_thread(_apply_analysis, analysis) @@ -884,12 +1078,12 @@ async def sse_callback(event_type, data, engine_inst): else: logger.info("[DREAMER] [Etape 4/5] Pas d'analyse LLM nécessaire (ou sautée).") report["pipeline"]["llm_analysis"] = {"skipped": True} - + # 5. Sauvegarder le rapport logger.info("[DREAMER] [Etape 5/5] Enregistrement du rapport...") report["duration_ms"] = round((time.time() - cycle_start) * 1000, 1) report_path = await asyncio.to_thread(_save_report, report) - + # Mettre à jour l'état global dreamer_state["last_run_at"] = report["timestamp"] dreamer_state["last_run_duration_ms"] = report["duration_ms"] @@ -899,11 +1093,11 @@ async def sse_callback(event_type, data, engine_inst): "summary": analysis.get("summary", "Consolidation automatique uniquement") if analysis else "Aucune session à analyser", "actions": report["actions"], } - + logger.info( f"[DREAMER] 🌙 Cycle de consolidation complet terminé en {report['duration_ms']}ms !" ) - + except Exception as e: logger.error(f"[DREAMER] ❌ Erreur critique dans le cycle: {e}", exc_info=True) dreamer_state["last_error"] = { @@ -912,7 +1106,7 @@ async def sse_callback(event_type, data, engine_inst): } finally: dreamer_state["running"] = False - + return report @@ -931,39 +1125,39 @@ async def dreamer_main_loop(): Vérifie toutes les 5 minutes si l'une des conditions est remplie. """ logger.info("[DREAMER] 🌙 Démarrage de la boucle autoDream") - + # Attendre que le système soit initialisé await asyncio.sleep(15) - + last_activity_check = time.time() - + while True: # Relire la config à chaque itération pa_config = _load_persistent_config() - + dreamer_state["enabled"] = pa_config.get("dreamer_enabled", True) dreamer_state["schedule"] = pa_config.get("dreamer_schedule", "02:00") dreamer_state["idle_trigger_hours"] = pa_config.get("dreamer_idle_trigger_hours", 3) - + if not pa_config.get("dreamer_enabled", True): await asyncio.sleep(300) # Vérifier toutes les 5 min si réactivé continue - + should_run = False trigger_reason = "" - + # Vérification 1 : Horaire fixe schedule_time = pa_config.get("dreamer_schedule", "02:00") try: now = datetime.now() target_hour, target_minute = map(int, schedule_time.split(":")) - + # Calculer le prochain déclenchement target_dt = now.replace(hour=target_hour, minute=target_minute, second=0) if target_dt < now: target_dt += timedelta(days=1) dreamer_state["next_scheduled"] = target_dt.isoformat() - + # Déclencher si on est dans la fenêtre de 5 minutes autour de l'heure cible if now.hour == target_hour and target_minute <= now.minute < target_minute + 5: # Vérifier qu'on n'a pas déjà fait un run aujourd'hui @@ -973,7 +1167,7 @@ async def dreamer_main_loop(): trigger_reason = f"Horaire fixe ({schedule_time})" except Exception: pass - + # Vérification 2 : Inactivité prolongée if not should_run: idle_hours = pa_config.get("dreamer_idle_trigger_hours", 3) @@ -984,7 +1178,7 @@ async def dreamer_main_loop(): last_session_ts = recent[0].get("started_at", 0) idle_since = time.time() - last_session_ts idle_hours_actual = idle_since / 3600 - + if idle_hours_actual >= idle_hours: # Vérifier qu'on n'a pas déjà consolidé dans les dernières idle_hours last_run = dreamer_state.get("last_run_at", "") @@ -1003,7 +1197,7 @@ async def dreamer_main_loop(): trigger_reason = f"Inactivité ({idle_hours_actual:.1f}h)" except Exception: pass - + # Exécuter le cycle si une condition est remplie if should_run: logger.info(f"[DREAMER] 🌙 Déclenchement: {trigger_reason}") @@ -1011,7 +1205,7 @@ async def dreamer_main_loop(): await run_dreamer_cycle(pa_config) except Exception as e: logger.error(f"[DREAMER] ❌ Erreur dans le cycle: {e}") - + # Vérifier toutes les 5 minutes await asyncio.sleep(300) @@ -1020,12 +1214,12 @@ async def dreamer_main_loop(): # API publique # ────────────────────────────────────────────────────────────────── -def get_dreamer_status() -> Dict[str, Any]: +def get_dreamer_status() -> dict[str, Any]: """Retourne l'état complet du dreamer pour l'API.""" return {**dreamer_state} -async def trigger_dreamer_manual() -> Dict[str, Any]: +async def trigger_dreamer_manual() -> dict[str, Any]: """Déclenche manuellement un cycle du dreamer (depuis l'IHM).""" pa_config = _load_persistent_config() return await run_dreamer_cycle(pa_config) diff --git a/api/routes/billing.py b/api/routes/billing.py index 221eabc..4ea700e 100644 --- a/api/routes/billing.py +++ b/api/routes/billing.py @@ -10,7 +10,11 @@ """ import logging + from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from api.services.billing_service import billing_sync_state logger = logging.getLogger(__name__) @@ -25,8 +29,8 @@ def get_tokens(): """Retourne les statistiques de consommation de tokens de la session courante.""" try: - from core.token_tracker import get_global_summary from core.session_history import get_session_stats + from core.token_tracker import get_global_summary return { "global": get_global_summary(), "session": get_session_stats(), @@ -56,7 +60,7 @@ def get_tokens_db( - session_id : Filtrer par session - limit : Nombre max d'entrées en mode history (défaut 100) """ - from core.session_history import get_token_stats, get_token_history + from core.session_history import get_token_history, get_token_stats if mode == "history": return get_token_history(limit=limit, session_id=session_id, model_filter=model) return get_token_stats(since_hours=since_hours, model_filter=model) @@ -104,6 +108,261 @@ def api_get_ide_conversations_stats(): return get_ide_conversations_stats() +# ────────────────────────────────────────────────────────────────── +# Comptes — Vue agrégée pour la page "Comptes API" de l'IHM +# ────────────────────────────────────────────────────────────────── + +# Métadonnées statiques (libellé, lien admin, type de plan) par provider. +# Pas de donnée live ici — uniquement pour habiller les entrées de +# get_quota_summary() et fournir un lien direct même pour les providers +# non suivis en quota (pas de scraping/API de solde disponible pour eux). +_PROVIDER_DASHBOARDS: dict[str, dict[str, str]] = { + "ollama": {"label": "Ollama (local)", "dashboard_url": "", "plan": "Local — gratuit"}, + "lmstudio": {"label": "LM Studio (local)", "dashboard_url": "", "plan": "Local — gratuit"}, + "gemini-free": {"label": "Google Gemini (GCP)", "dashboard_url": "https://console.cloud.google.com/billing", "plan": "Free Tier + GCP"}, + "deepseek-free": {"label": "DeepSeek", "dashboard_url": "https://platform.deepseek.com/usage", "plan": "API pay-as-you-go"}, + "anthropic-claude-haiku": {"label": "Anthropic (budget global)", "dashboard_url": "https://console.anthropic.com/settings/cost", "plan": "API pay-as-you-go"}, +} + +# Providers connus du moteur (core/llm_gateway.py) mais absents de +# get_quota_summary() : pas de suivi de quota/solde en direct aujourd'hui, +# juste un lien vers la console d'admin pour vérification manuelle. +_UNTRACKED_PROVIDER_LINKS: dict[str, dict[str, str]] = { + "claude-subscription": {"label": "Claude.ai (abonnement)", "dashboard_url": "https://claude.ai/settings/usage", "plan": "Abonnement mensuel"}, + "gemini-subscription": {"label": "Gemini (abonnement Google AI)", "dashboard_url": "https://gemini.google.com/app", "plan": "Abonnement mensuel"}, + "mistral": {"label": "Mistral AI", "dashboard_url": "https://console.mistral.ai/usage", "plan": "API pay-as-you-go — pas d'API de solde documentée, dashboard email/mot de passe standard"}, + "cohere": {"label": "Cohere", "dashboard_url": "https://dashboard.cohere.com/billing", "plan": "Trial Key gratuite (Command R/R+) — jamais utilisée par le moteur à ce jour, pas de coût"}, + "cerebras": {"label": "Cerebras", "dashboard_url": "https://cloud.cerebras.ai/", "plan": "Free Tier"}, + "dashscope": { + "label": "Alibaba DashScope (Coding Plan)", + "dashboard_url": "https://modelstudio.console.alibabacloud.com/", + "plan": "Coding Plan Lite — forfait requêtes (1200/5h, 9000/sem, 18000/mois), pas d'API de solde", + }, + "zhipu": {"label": "Zhipu AI / Z.ai (GLM)", "dashboard_url": "https://open.bigmodel.cn/usercenter/financial", "plan": "API pay-as-you-go — dashboard en chinois, login téléphone/WeChat uniquement"}, + "openrouter": {"label": "OpenRouter", "dashboard_url": "https://openrouter.ai/credits", "plan": "API pay-as-you-go"}, + "minimax": {"label": "MiniMax", "dashboard_url": "https://platform.minimaxi.com/console", "plan": "API pay-as-you-go — dashboard en chinois, login téléphone/WeChat uniquement"}, + "grok": {"label": "xAI (Grok)", "dashboard_url": "https://console.x.ai/", "plan": "API pay-as-you-go"}, + "github": {"label": "GitHub Models", "dashboard_url": "https://github.com/settings/billing", "plan": "Free Tier"}, +} + + +def _status_for(name: str, q: dict) -> str: + """Statut lisible : dispo locale pour ollama/lmstudio, quota sinon.""" + if name in ("ollama", "lmstudio"): + return "active" if q.get("available") else "offline" + return "active" if q.get("available") else "quota_exceeded" + + +class ManualBalanceBody(BaseModel): + provider: str + balance_usd: float + note: str = "" + + +@router.post("/api/billing/manual") +def set_manual_provider_balance(body: ManualBalanceBody): + """ + Enregistre un solde saisi à la main pour un provider sans API de solde + connue (Mistral, Cohere, Cerebras, Zhipu, MiniMax, xAI, GitHub...). Le + solde est réaffiché dans /api/billing avec un statut "manuel" et + l'horodatage de la saisie, tant qu'une vraie API n'existe pas pour ce + provider. + """ + from core.token_tracker import set_manual_balance + set_manual_balance(body.provider, body.balance_usd, body.note) + return {"status": "ok", "provider": body.provider, "balance_usd": body.balance_usd} + + +@router.get("/api/billing") +async def get_billing_accounts(): + """ + Vue agrégée "Comptes API" consommée par ihm-v2/src/api/accounts.ts::fetchAccounts. + + Croise les quotas gratuits temps réel (core.budget_guard.get_quota_summary, + déjà exposés séparément sous /api/backlog/quota) avec les soldes réels + scrapés (core.token_tracker.get_global_summary()["real_billing"], alimentés + par la sync GCP/Claude) et une table statique de liens d'admin par provider. + Les providers non suivis en quota apparaissent quand même, avec un lien + d'admin mais sans donnée de conso (status "non_suivi"). + """ + try: + import asyncio as _asyncio + from datetime import datetime + + from core.budget_guard import BudgetGuard + from core.gcp_oauth_client import get_gcp_client + from core.provider_balances import ( + fetch_deepseek_balance_usd, + fetch_minimax_token_plan_status, + fetch_openrouter_credits, + fetch_openrouter_key_info, + fetch_xai_balance_usd, + ) + from core.session_history import get_token_stats + from core.token_tracker import get_global_summary, get_manual_balances + + guard = BudgetGuard() + gcp_client = get_gcp_client() + gcp_available = gcp_client.available + ( + quota_summary, deepseek_live, openrouter_live, openrouter_key, + gcp_month_cost, gcp_credits, minimax_token_plan, xai_balance, + ) = await _asyncio.gather( + guard.get_quota_summary(), + fetch_deepseek_balance_usd(), + fetch_openrouter_credits(), + fetch_openrouter_key_info(), + _asyncio.to_thread(gcp_client.get_bigquery_month_cost_eur) if gcp_available else _asyncio.sleep(0, result=None), + _asyncio.to_thread(gcp_client.get_credit_grants_status) if gcp_available else _asyncio.sleep(0, result=None), + fetch_minimax_token_plan_status(), + fetch_xai_balance_usd(), + ) + real_billing = get_global_summary().get("real_billing", {}) + manual_balances = get_manual_balances() + last_sync = billing_sync_state.get("last_sync") + + # Dépense réelle du mois calendaire courant (pas un solde de compte, + # cf. bug signalé : le total de la bannière sommait les soldes). + now_dt = datetime.now() + month_start = now_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + hours_since_month_start = max((now_dt - month_start).total_seconds() / 3600, 1) + total_cost_usd_month = round( + get_token_stats(since_hours=hours_since_month_start).get("total_cost", 0.0), 4 + ) + + providers: list[dict] = [] + + for name, q in quota_summary.get("providers", {}).items(): + meta = _PROVIDER_DASHBOARDS.get(name, {}) + limit = q.get("limit") or 0 + used = q.get("used") or 0 + is_usd = q.get("unit") == "USD" + balance_usd = None + balance_eur = None + plan = meta.get("plan", q.get("metric", "")) + if name == "deepseek-free": + # API officielle DeepSeek en priorité, fallback sur le solde scrapé + balance_usd = deepseek_live if deepseek_live is not None else real_billing.get("deepseek_balance_usd") + elif name == "gemini-free": + # Export de facturation BigQuery (format FOCUS, déjà configuré sur + # ha-delta) en priorité — dépense RÉELLE du mois en EUR (devise + # vérifiée du compte, PAS USD), pas un solde (GCP est du + # pay-as-you-go). Fallback sur le scraper Chrome si l'export + # n'est pas dispo. + crédits promo restants (relevés le 07/07/2026, + # recalculés en direct depuis la conso réelle). + if gcp_month_cost is not None: + balance_eur = gcp_month_cost + plan = f"{plan} — dépense du mois (BigQuery)" + else: + balance_usd = real_billing.get("gemini_gcp_cost_usd") + if gcp_credits: + remaining_total = round(sum(c["remaining_eur"] for c in gcp_credits if not c["expired"]), 2) + if remaining_total > 0: + plan = f"{plan} — {remaining_total}€ de crédits promo restants" + elif name == "anthropic-claude-haiku": + # Coût réel scrapé sur console.anthropic.com/settings/cost (pas de + # solde : API pay-as-you-go). Dépense, pas budget alloué. + anthropic_cost = real_billing.get("anthropic_api_cost_usd") + if anthropic_cost is not None: + balance_usd = anthropic_cost + plan = f"{plan} — dépense du mois (console.anthropic.com)" + + providers.append({ + "name": meta.get("label", name), + "balance_usd": balance_usd, + "balance_eur": balance_eur, + "quota_rpm": None, + "quota_rpd": None if is_usd else limit, + "used_rpm": None, + "used_rpd": None if is_usd else used, + "used_pct": round((used / limit) * 100, 1) if limit else 0.0, + "last_sync": last_sync, + "dashboard_url": meta.get("dashboard_url", ""), + "status": _status_for(name, q), + "plan": plan, + }) + + claude_pct = real_billing.get("claude_usage_pct") + untracked = dict(_UNTRACKED_PROVIDER_LINKS) + for name, meta in untracked.items(): + used_pct = claude_pct if name == "claude-subscription" and claude_pct is not None else 0.0 + balance_usd = None + quota_rpd = None + used_rpd = None + plan = meta["plan"] + status = "non_suivi" + if name == "openrouter" and openrouter_live is not None: + # API officielle OpenRouter : GET /api/v1/credits (solde global) + # + GET /api/v1/key (plafond configuré sur la clé + conso + # réelle jour/semaine/mois) — plus besoin de se contenter d'un lien. + balance_usd = openrouter_live["balance"] + status = "active" + if openrouter_key: + key_limit = openrouter_key.get("limit") + if key_limit: + quota_rpd = key_limit + used_rpd = openrouter_key.get("usage_monthly", 0) + used_pct = round((used_rpd / key_limit) * 100, 1) if key_limit else 0.0 + else: + plan = f"{meta['plan']} — pas de plafond configuré sur la clé" + elif name == "gemini-subscription": + # Best-effort scraper (gemini.google.com > Réglages > Usage limits) : + # pas de % fiable comme Claude, juste le texte brut trouvé sur la page. + gemini_summary = real_billing.get("gemini_subscription_summary") + if gemini_summary: + status = "active" + plan = f"{meta['plan']} — {gemini_summary}" + elif name == "minimax" and minimax_token_plan is not None: + # API officielle (GET /v1/token_plan/remains, testée en direct 07/07/2026) : + # pas de solde ici (MiniMax n'a pas d'API pour le pay-as-you-go), juste + # de quoi confirmer quel système de facturation est actif. + if minimax_token_plan["has_token_plan"]: + plan = f"{meta['plan']} — forfait Token Plan actif : {minimax_token_plan['model_remains']} restants" + else: + plan = f"{meta['plan']} — pas de forfait Token Plan, facturé au solde (pas d'API pour ce mode)" + elif name == "grok" and xai_balance is not None: + # Management API xAI (GET /v1/billing/teams/{team_id}/prepaid/balance, + # clé XAI_MANAGEMENT_API_KEY séparée) — testée en direct 07/07/2026. + balance_usd = xai_balance + status = "active" + + entry_last_sync = last_sync if name == "claude-subscription" else None + if name == "gemini-subscription": + entry_last_sync = real_billing.get("gemini_subscription_last_sync") + manual = manual_balances.get(name) + if status == "non_suivi" and manual: + # Pas d'API pour ce provider : solde saisi à la main dans l'IHM + # (POST /api/billing/manual), affiché avec son propre horodatage. + balance_usd = manual["balance_usd"] + status = "manuel" + entry_last_sync = manual["updated_at"] + if manual.get("note"): + plan = f"{plan} — {manual['note']}" + + providers.append({ + "name": meta["label"], + "balance_usd": balance_usd, + "balance_eur": None, + "quota_rpm": None, + "quota_rpd": quota_rpd, + "used_rpm": None, + "used_rpd": used_rpd, + "used_pct": used_pct, + "last_sync": entry_last_sync, + "dashboard_url": meta["dashboard_url"], + "status": status, + "plan": plan, + # Clé technique stable pour POST /api/billing/manual (le "name" + # affiché est un libellé humain, pas un identifiant fiable). + "provider_key": name, + }) + + return {"providers": providers, "total_cost_usd_month": total_cost_usd_month} + except Exception as e: + logger.error(f"[BILLING] Erreur agrégation /api/billing : {e}") + raise HTTPException(status_code=500, detail=str(e)) + + # ────────────────────────────────────────────────────────────────── # Billing — Sync & historique # ────────────────────────────────────────────────────────────────── @@ -194,9 +453,10 @@ async def receive_otlp_metrics(payload: dict): """ global _otlp_cumulative_cache try: - from core.session_history import record_token_usage import time + from core.session_history import record_token_usage + # Guardrail anti fuite mémoire if len(_otlp_cumulative_cache) > 2000: _otlp_cumulative_cache.clear() @@ -204,7 +464,7 @@ async def receive_otlp_metrics(payload: dict): resource_metrics = payload.get("resourceMetrics", []) records_added = 0 service_name = "unknown_service" - + for rm in resource_metrics: resource_attrs = { attr.get("key"): attr.get("value", {}).get("stringValue") @@ -217,31 +477,31 @@ async def receive_otlp_metrics(payload: dict): metrics = sm.get("metrics", []) for metric in metrics: metric_name = metric.get("name", "") - + # Claude Code pousse des métriques de type claude_code.token.usage if "token" in metric_name.lower(): sum_data = metric.get("sum", {}) or metric.get("gauge", {}) data_points = sum_data.get("dataPoints", []) - + # Accumulateurs temporaires par modèle pour cette requête model_tokens = {} - + for dp in data_points: attrs = { attr.get("key"): attr.get("value", {}).get("stringValue") for attr in dp.get("attributes", []) } - + model = attrs.get("model") or attrs.get("model_name") or "claude-code-unknown" token_type = attrs.get("token_type") or attrs.get("type") or "prompt" - + val = int(dp.get("asInt", 0) or dp.get("asDouble", 0)) if val <= 0: continue - + start_time = dp.get("startTimeUnixNano") or "0" cache_key = (start_time, model, token_type) - + # Calcul du delta en cas de métriques cumulatives (monotoniques) if start_time != "0" and cache_key in _otlp_cumulative_cache: prev_val = _otlp_cumulative_cache[cache_key] @@ -254,25 +514,25 @@ async def receive_otlp_metrics(payload: dict): if start_time != "0": _otlp_cumulative_cache[cache_key] = val actual_val = val - + if model not in model_tokens: model_tokens[model] = {"input": 0, "output": 0} - + if token_type in ("input", "prompt", "cache_read", "cache_creation"): model_tokens[model]["input"] += actual_val elif token_type in ("output", "completion"): model_tokens[model]["output"] += actual_val - + # Persister les deltas calculés for model, counts in model_tokens.items(): if counts["input"] > 0 or counts["output"] > 0: channel = "claude_cli" if "claude" in service_name.lower() or "claude" in model.lower() else "antigravity_ide" - + # Claude 3.5 Sonnet: $3/M input, $15/M output (estimations) input_rate = 3.0 / 1_000_000 if "claude" in model.lower() else 0.075 / 1_000_000 output_rate = 15.0 / 1_000_000 if "claude" in model.lower() else 0.30 / 1_000_000 cost = (counts["input"] * input_rate) + (counts["output"] * output_rate) - + from core.async_db_serializer import AsyncDBSerializer serializer = AsyncDBSerializer.get_instance() await serializer.execute(lambda: record_token_usage( @@ -285,12 +545,12 @@ async def receive_otlp_metrics(payload: dict): agent_name=service_name )) records_added += 1 - + if records_added > 0: logger.info(f"[OTLP] Reçu et enregistré {records_added} métriques de tokens depuis {service_name}") - + return {"status": "success", "processed_records": records_added} - + except Exception as e: logger.error(f"[OTLP] Erreur traitement metrics : {e}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/api/routes/ha.py b/api/routes/ha.py index 5353d50..47acd51 100644 --- a/api/routes/ha.py +++ b/api/routes/ha.py @@ -7,16 +7,20 @@ Auteur : Antigravity IDE + Axel — 2026-06-04 """ -import os import logging -from core.ha_tls import ha_ssl_context # [P0-1.5] politique TLS HA centralisée +import os +from typing import Any + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from core.ha_tls import ha_ssl_context, ha_tls_pinned_hostname # [P0-1.5] politique TLS HA centralisée from core.validation import ( # [P0-1.6] validation des identifiants HA - is_valid_ha_entity_id, is_valid_ha_domain, is_valid_ha_service_name, + is_valid_ha_domain, + is_valid_ha_entity_id, + is_valid_ha_service_name, validate_service_data, ) -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel -from typing import Optional, Dict, Any logger = logging.getLogger(__name__) @@ -31,19 +35,25 @@ class HAControlBody(BaseModel): """Corps de la requête pour contrôler une entité HA.""" entity_id: str service: str # Ex: "turn_on", "turn_off", "set_temperature" - domain: Optional[str] = None # Déduit de entity_id si non fourni - service_data: Optional[Dict[str, Any]] = None + domain: str | None = None # Déduit de entity_id si non fourni + service_data: dict[str, Any] | None = None class HACommandBody(BaseModel): """Corps de la requête pour les commandes HA via pipeline domotique.""" prompt: str - session_id: Optional[str] = None + session_id: str | None = None def _get_ha_credentials(): - """Retourne (ha_url, ha_token) depuis les variables d'environnement.""" - ha_token = os.environ.get("HASS_TOKEN") - ha_url = os.environ.get("HASS_URL", "http://${HA_HOST:-192.168.1.x}:8123") + """ + Retourne (ha_url, ha_token) depuis les variables d'environnement. + + `HASS_*` est la convention de ce module ; `HA_*` est acceptée en repli car + les outils MCP (`core/mcp_tools/homeassistant.py`) utilisent cette forme et + le `.env` définit historiquement les deux. + """ + ha_token = os.environ.get("HASS_TOKEN") or os.environ.get("HA_TOKEN") + ha_url = os.environ.get("HASS_URL") or os.environ.get("HA_URL") or "http://${HA_HOST:-192.168.1.x}:8123" if not ha_token: raise HTTPException(status_code=500, detail="HASS_TOKEN non configuré dans l'environnement.") return ha_url, ha_token @@ -53,6 +63,165 @@ def _get_ha_credentials(): # Routes # ────────────────────────────────────────────────────────────────── +@router.get("/api/ha/health") +async def ha_health(): + """ + Diagnostic réel de la liaison Home Assistant (vue « Domotique » de l'IHM). + + Ne renvoie JAMAIS un état « connecté » supposé : la joignabilité est établie + par un appel effectif à `GET /api/` de Home Assistant, chronométré. En cas + d'échec, la cause exacte est remontée telle quelle. + """ + import time + + ha_token = os.environ.get("HASS_TOKEN") or os.environ.get("HA_TOKEN") + ha_url = os.environ.get("HASS_URL") or os.environ.get("HA_URL") or "http://${HA_HOST:-192.168.1.x}:8123" + verify_tls = os.environ.get("HA_VERIFY_TLS", "true").lower() not in ("false", "0", "no") + + result: dict[str, Any] = { + "url": ha_url, + "token_configured": bool(ha_token), + "verify_tls": verify_tls, + "ca_bundle": os.environ.get("HA_CA_BUNDLE") or None, + "tls_server_hostname": ha_tls_pinned_hostname() or None, + "reachable": False, + "latency_ms": None, + "version": None, + "entity_count": None, + "domains": {}, + "error": None, + } + + if not ha_token: + result["error"] = "HASS_TOKEN absent du .env du moteur — aucune requête tentée." + return result + + headers = {"Authorization": f"Bearer {ha_token}", "Content-Type": "application/json"} + + import aiohttp + try: + started = time.perf_counter() + async with aiohttp.ClientSession() as session: + # 1. Joignabilité + version. + async with session.get( + f"{ha_url}/api/", + headers=headers, + ssl=ha_ssl_context(), + timeout=aiohttp.ClientTimeout(total=6), + ) as resp: + result["latency_ms"] = round((time.perf_counter() - started) * 1000, 1) + if resp.status == 401: + result["error"] = "Token refusé par Home Assistant (401)." + return result + if resp.status != 200: + result["error"] = f"Home Assistant a répondu {resp.status}." + return result + result["reachable"] = True + + # 2. Inventaire des entités, pour situer le périmètre exposé au moteur. + async with session.get( + f"{ha_url}/api/states", + headers=headers, + ssl=ha_ssl_context(), + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status == 200: + states = await resp.json() + result["entity_count"] = len(states) + domains: dict[str, int] = {} + for entity in states: + domain = entity.get("entity_id", "").split(".", 1)[0] + if domain: + domains[domain] = domains.get(domain, 0) + 1 + result["domains"] = dict(sorted(domains.items(), key=lambda kv: -kv[1])) + + # 3. Version : exposée par l'entité de configuration si disponible. + async with aiohttp.ClientSession() as session: + async with session.get( + f"{ha_url}/api/config", + headers=headers, + ssl=ha_ssl_context(), + timeout=aiohttp.ClientTimeout(total=6), + ) as resp: + if resp.status == 200: + cfg = await resp.json() + result["version"] = cfg.get("version") + result["location_name"] = cfg.get("location_name") + + return result + + except aiohttp.ClientConnectorError as e: + result["error"] = f"Connexion impossible : {e}" + return result + except TimeoutError: + result["error"] = "Délai dépassé — Home Assistant n'a pas répondu à temps." + return result + except Exception as e: + result["error"] = str(e) + return result + + +@router.get("/api/ha/entities") +async def ha_entities( + search: str = "", + domain: str = "", + limit: int = 200, +): + """ + Liste les entités Home Assistant, filtrables par domaine et par texte. + + Sert l'explorateur d'entités de l'IHM : c'est le périmètre exact sur lequel + le moteur et l'assistant vocal peuvent agir. + """ + ha_url, ha_token = _get_ha_credentials() + headers = {"Authorization": f"Bearer {ha_token}", "Content-Type": "application/json"} + safe_limit = max(1, min(limit, 1000)) + + import aiohttp + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{ha_url}/api/states", + headers=headers, + ssl=ha_ssl_context(), + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status != 200: + raise HTTPException(status_code=resp.status, detail=f"Erreur Home Assistant : {resp.status}") + states = await resp.json() + except aiohttp.ClientConnectorError as e: + raise HTTPException(status_code=503, detail=f"Impossible de se connecter à Home Assistant : {e}") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + needle = search.strip().lower() + matched = [] + for entity in states: + entity_id = entity.get("entity_id", "") + if domain and not entity_id.startswith(f"{domain}."): + continue + friendly = entity.get("attributes", {}).get("friendly_name", "") or "" + if needle and needle not in entity_id.lower() and needle not in friendly.lower(): + continue + matched.append({ + "entity_id": entity_id, + "friendly_name": friendly, + "state": entity.get("state"), + "unit": entity.get("attributes", {}).get("unit_of_measurement"), + "device_class": entity.get("attributes", {}).get("device_class"), + "last_changed": entity.get("last_changed"), + }) + + matched.sort(key=lambda e: e["entity_id"]) + return { + "total_matched": len(matched), + "returned": min(len(matched), safe_limit), + "entities": matched[:safe_limit], + } + + @router.get("/api/ha/state/{entity_id}") async def get_ha_state(entity_id: str): """Récupère l'état d'une entité de Home Assistant.""" diff --git a/api/routes/metrics.py b/api/routes/metrics.py index 4042b17..4afc107 100644 --- a/api/routes/metrics.py +++ b/api/routes/metrics.py @@ -15,19 +15,23 @@ - GET /api/metrics/agents : Performance par agent """ +import asyncio +import logging import os -import time import sqlite3 -import logging +import time +import uuid from datetime import datetime -from fastapi import APIRouter, Query + +from fastapi import APIRouter, HTTPException, Query from fastapi.responses import RedirectResponse +from pydantic import BaseModel, Field logger = logging.getLogger(__name__) router = APIRouter(tags=["Métriques & Télémétrie"]) -from core.runtime_db import get_db_path, get_connection +from core.runtime_db import get_connection, get_db_path # Chemins des bases de données _ENGINE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -104,17 +108,17 @@ async def get_elo_scores(): try: from core.elo_scorer import get_all_scores, get_domain_leaderboard all_scores = get_all_scores() - + # Extraire les domaines uniques domains = set() for model_domains in all_scores.values(): domains.update(model_domains.keys()) - + # Leaderboards par domaine leaderboards = {} for domain in sorted(domains): leaderboards[domain] = get_domain_leaderboard(domain, top_n=10) - + return { "scores": all_scores, "domains": sorted(domains), @@ -140,6 +144,274 @@ async def get_cost_per_success_endpoint(): return {"providers": {}} +# ────────────────────────────────────────────────────────────────── +# Comparatif de modèles (vue « Benchmarks » de l'IHM) +# +# Deux natures de données, jamais mélangées dans l'affichage : +# - « observed » : ce que les modèles ont RÉELLEMENT fait en production +# (token_usage + model_elo_scores + routing_decisions). Aucune estimation. +# - « runs » : comparatifs déclenchés explicitement depuis l'IHM, dont la +# latence est chronométrée côté serveur autour de l'appel provider. +# ────────────────────────────────────────────────────────────────── + +# Garde-fous : un comparatif déclenche de VRAIS appels LLM facturés. +_BENCH_MAX_MODELS = 8 +_BENCH_MAX_TOKENS = 2048 + + +class BenchmarkRequest(BaseModel): + """Corps de POST /api/metrics/benchmarks — lance un comparatif réel.""" + prompt: str = Field(..., min_length=1, description="Prompt envoyé à tous les modèles") + models: list[str] = Field(..., min_length=1, description="Modèles/tiers à comparer") + system_prompt: str | None = Field(None, description="Prompt système commun") + max_tokens: int | None = Field(512, ge=1, le=_BENCH_MAX_TOKENS) + temperature: float | None = Field(0.3, ge=0.0, le=2.0) + + +def _observed_performance(since_ts: float) -> list: + """ + Performance réellement observée par modèle, croisée depuis trois sources. + + Rien n'est inventé : un champ absent en base reste `None` côté API, et l'IHM + affiche « — » plutôt qu'un zéro trompeur. + """ + observed: dict[str, dict] = {} + + # 1. Consommation réelle (token_usage) — appels, tokens, coût facturé. + for row in _safe_query( + _SESSION_DB, + "SELECT model, COUNT(*) AS calls, SUM(total_tokens) AS total_tokens, " + "SUM(cost_usd) AS cost_usd " + "FROM token_usage WHERE timestamp > ? GROUP BY model", + (since_ts,), + ): + model = row["model"] + if not model: + continue + calls = row["calls"] or 0 + observed[model] = { + "model": model, + "calls": calls, + "total_tokens": row["total_tokens"] or 0, + "cost_usd": round(row["cost_usd"] or 0.0, 6), + "avg_cost_per_call_usd": round((row["cost_usd"] or 0.0) / calls, 6) if calls else None, + "elo": None, + "matches": None, + "win_rate": None, + "avg_latency_ms": None, + "success_rate": None, + } + + # 2. Scores Elo (model_elo_scores) — agrégés sur tous les domaines. + for row in _safe_query( + _SESSION_DB, + "SELECT model_name, SUM(total_matches) AS matches, SUM(wins) AS wins, " + "SUM(losses) AS losses, AVG(elo_score) AS elo, AVG(avg_latency_ms) AS latency " + "FROM model_elo_scores GROUP BY model_name", + ): + model = row["model_name"] + if not model: + continue + entry = observed.setdefault(model, { + "model": model, "calls": 0, "total_tokens": 0, "cost_usd": 0.0, + "avg_cost_per_call_usd": None, "success_rate": None, + }) + matches = row["matches"] or 0 + entry["elo"] = round(row["elo"], 1) if row["elo"] is not None else None + entry["matches"] = matches + entry["win_rate"] = round((row["wins"] or 0) / matches * 100, 1) if matches else None + entry["avg_latency_ms"] = round(row["latency"], 1) if row["latency"] is not None else None + + # 3. Latence et succès mesurés au routage (routing_decisions). + for row in _safe_query( + _ROUTING_DB, + "SELECT resolved_model, AVG(latency_ms) AS latency, COUNT(*) AS n, " + "SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS ok " + "FROM routing_decisions WHERE timestamp > ? AND resolved_model IS NOT NULL " + "GROUP BY resolved_model", + (since_ts,), + ): + model = row["resolved_model"] + if not model: + continue + entry = observed.setdefault(model, { + "model": model, "calls": 0, "total_tokens": 0, "cost_usd": 0.0, + "avg_cost_per_call_usd": None, "elo": None, "matches": None, "win_rate": None, + }) + n = row["n"] or 0 + # La latence de routage est une mesure directe : elle prime sur la moyenne Elo. + if row["latency"] is not None: + entry["avg_latency_ms"] = round(row["latency"], 1) + entry["success_rate"] = round((row["ok"] or 0) / n * 100, 1) if n else None + + return sorted(observed.values(), key=lambda e: (e.get("calls") or 0), reverse=True) + + +def _load_runs(limit: int) -> list: + """Charge les derniers comparatifs déclenchés depuis l'IHM, avec leurs résultats.""" + runs = _safe_query( + _SESSION_DB, + "SELECT run_id, prompt, system_prompt, created_at, finished_at, status " + "FROM benchmark_runs ORDER BY created_at DESC LIMIT ?", + (limit,), + ) + for run in runs: + run["results"] = _safe_query( + _SESSION_DB, + "SELECT model, status, latency_ms, response_text, response_chars, " + "prompt_tokens, completion_tokens, cost_usd, error_message " + "FROM benchmark_results WHERE run_id = ? ORDER BY latency_ms", + (run["run_id"],), + ) + return runs + + +@router.get("/api/metrics/benchmarks") +async def get_benchmarks( + limit: int = Query(10, ge=1, le=50, description="Nombre de comparatifs retournés"), + period: str = Query("30d", description="Fenêtre pour la performance observée"), +): + """ + Comparatif des modèles : performance observée en production + comparatifs manuels. + + Aucune donnée n'est simulée. Si une métrique n'a jamais été mesurée pour un + modèle, elle vaut `null` — c'est à l'IHM de l'afficher comme non mesurée. + """ + period_map = {"1h": 3600, "6h": 6 * 3600, "24h": 24 * 3600, "7d": 7 * 86400, "30d": 30 * 86400} + since_ts = time.time() - period_map.get(period, 30 * 86400) + return { + "period": period, + "generated_at": datetime.now().isoformat(), + "observed": _observed_performance(since_ts), + "runs": _load_runs(limit), + } + + +def _bench_one(model: str, system_prompt: str, user_prompt: str, + max_tokens: int, temperature: float) -> dict: + """ + Exécute le prompt sur un modèle et chronomètre l'appel. Bloquant : appelé via + asyncio.to_thread pour que les modèles soient comparés en parallèle. + """ + from core.llm_gateway import LLMGateway + + started = time.perf_counter() + try: + provider = LLMGateway().get_provider(model) + response = provider.generate( + system_prompt, user_prompt, temperature=temperature, max_tokens=max_tokens + ) + elapsed_ms = (time.perf_counter() - started) * 1000 + + if isinstance(response, dict): + response = response.get("content", str(response)) + text = str(response) + + # Comptage de tokens : approximation explicite (les providers ne renvoient + # pas tous un usage). Le coût en découle, il est donc lui aussi approché — + # c'est signalé à l'IHM par `tokens_estimated`. + prompt_tokens = max(1, len(f"{system_prompt} {user_prompt}".split())) + completion_tokens = max(1, len(text.split())) + + from core.pricing import get_model_pricing + price = get_model_pricing(model) + cost = prompt_tokens * price.get("input", 0.0) + completion_tokens * price.get("output", 0.0) + + # La consommation réelle est comptabilisée comme n'importe quel appel moteur. + try: + from core.token_tracker import record_usage + record_usage(model, prompt_tokens, completion_tokens, cost_usd=cost) + except Exception as track_err: + logger.warning(f"[BENCH] Comptabilisation tokens impossible ({model}) : {track_err}") + + return { + "model": model, "status": "success", "latency_ms": round(elapsed_ms, 1), + "response_text": text, "response_chars": len(text), + "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, + "cost_usd": round(cost, 6), "error_message": None, + } + except Exception as e: + elapsed_ms = (time.perf_counter() - started) * 1000 + logger.warning(f"[BENCH] Échec sur {model} : {e}") + return { + "model": model, "status": "error", "latency_ms": round(elapsed_ms, 1), + "response_text": None, "response_chars": 0, + "prompt_tokens": None, "completion_tokens": None, + "cost_usd": None, "error_message": str(e), + } + + +@router.post("/api/metrics/benchmarks") +async def run_benchmark(body: BenchmarkRequest): + """ + Lance un comparatif réel : le même prompt est envoyé en parallèle à chaque + modèle, la latence est chronométrée côté serveur et la consommation est + comptabilisée normalement. + + ⚠ Déclenche de vrais appels LLM facturés (plafonné à 8 modèles par run). + """ + models = list(dict.fromkeys(m.strip() for m in body.models if m and m.strip())) + if not models: + raise HTTPException(status_code=400, detail="Aucun modèle valide fourni.") + if len(models) > _BENCH_MAX_MODELS: + raise HTTPException( + status_code=400, + detail=f"{len(models)} modèles demandés : maximum {_BENCH_MAX_MODELS} par comparatif.", + ) + + run_id = f"bench-{uuid.uuid4().hex[:12]}" + system_prompt = (body.system_prompt or "Tu es un assistant IA expert. Réponds de façon concise.").strip() + created_at = time.time() + + conn = get_connection() + try: + conn.execute( + "INSERT INTO benchmark_runs (run_id, prompt, system_prompt, created_at, status) " + "VALUES (?, ?, ?, ?, 'running')", + (run_id, body.prompt, system_prompt, created_at), + ) + conn.commit() + finally: + conn.close() + + results = await asyncio.gather(*[ + asyncio.to_thread( + _bench_one, model, system_prompt, body.prompt, + body.max_tokens or 512, body.temperature if body.temperature is not None else 0.3, + ) + for model in models + ]) + + finished_at = time.time() + conn = get_connection() + try: + for res in results: + conn.execute( + "INSERT INTO benchmark_results (run_id, model, status, latency_ms, response_text, " + "response_chars, prompt_tokens, completion_tokens, cost_usd, error_message, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (run_id, res["model"], res["status"], res["latency_ms"], res["response_text"], + res["response_chars"], res["prompt_tokens"], res["completion_tokens"], + res["cost_usd"], res["error_message"], finished_at), + ) + conn.execute( + "UPDATE benchmark_runs SET finished_at = ?, status = ? WHERE run_id = ?", + (finished_at, "completed", run_id), + ) + conn.commit() + finally: + conn.close() + + return { + "run_id": run_id, + "prompt": body.prompt, + "created_at": created_at, + "finished_at": finished_at, + "status": "completed", + "results": results, + } + + @router.get("/api/metrics/routing") async def get_routing_stats_endpoint(): """Statistiques détaillées de routage (catégories, fast/slow path).""" @@ -212,13 +484,13 @@ def _get_agent_stats(since_ts: float) -> list: "GROUP BY model_name ORDER BY total_calls DESC", (since_ts,), ) - + for row in rows: total = row.get("total_calls", 1) or 1 row["success_rate"] = round( (row.get("successes", 0) / total) * 100, 1 ) - + return rows diff --git a/api/routes/setup.py b/api/routes/setup.py new file mode 100644 index 0000000..bd9da26 --- /dev/null +++ b/api/routes/setup.py @@ -0,0 +1,299 @@ +""" +api/routes/setup.py — Diagnostic d'installation du moteur. + +Répond à une question simple que l'IHM ne savait pas poser : « mon installation +est-elle complète et fonctionnelle ? ». Chaque contrôle est EFFECTIF (fichier lu, +table comptée, connexion tentée) et porte une remédiation concrète quand il +échoue — jamais un statut déclaratif. + +Lecture seule : ce module ne modifie rien, il constate. + +Auteur : Claude + Axel — 2026-07-28 +""" + +import logging +import os +import sqlite3 + +from fastapi import APIRouter + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/setup", tags=["Installation"]) + +_ENGINE_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Providers dont l'absence de clé désactive simplement le provider (jamais bloquant). +_PROVIDER_KEYS = [ + ("GEMINI_API_KEY", "Gemini Free Tier"), + ("GEMINI_PAYANT_API_KEY", "Gemini payant (GCP)"), + ("DEEPSEEK_API_KEY", "DeepSeek"), + ("ANTHROPIC_API_KEY", "Anthropic"), + ("MISTRAL_API_KEY", "Mistral"), + ("COHERE_API_KEY", "Cohere"), + ("OPENROUTER_API_KEY", "OpenRouter"), + ("XAI_API_KEY", "xAI (Grok)"), + ("MINIMAX_API_KEY", "MiniMax"), + ("DEEPINFRA_API_KEY", "DeepInfra"), + ("ZHIPU_API_KEY", "Zhipu / GLM"), + ("GITHUB_TOKEN", "GitHub Models"), +] + + +def _check(name: str, label: str, status: str, detail: str, fix: str | None = None) -> dict: + """Construit un contrôle. `status` ∈ ok | warn | error | info.""" + return {"name": name, "label": label, "status": status, "detail": detail, "fix": fix} + + +def _table_count(db_path: str, table: str) -> int | None: + """Compte les lignes d'une table, ou None si la base/table est absente.""" + if not os.path.exists(db_path): + return None + try: + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + try: + return conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + finally: + conn.close() + except Exception: + return None + + +@router.get("/diagnostics") +async def setup_diagnostics(): + """ + État complet de l'installation, groupé par étape. + + Le champ `blocking` distingue ce qui empêche le moteur de fonctionner de ce + qui désactive seulement une capacité optionnelle. + """ + groups: list[dict] = [] + + # ── 1. Authentification et accès ────────────────────────────────── + auth_checks = [] + api_key = os.environ.get("MOTEUR_API_KEY", "") + if api_key: + auth_checks.append(_check( + "api_key", "Clé API du moteur", "ok", + f"Définie ({len(api_key)} caractères). Les routes /api sont protégées.", + )) + else: + auth_checks.append(_check( + "api_key", "Clé API du moteur", "error", + "MOTEUR_API_KEY absente : les routes /api sensibles répondent 503 (fail-closed).", + "Définir MOTEUR_API_KEY dans le .env du moteur, puis redémarrer.", + )) + + cors = os.environ.get("MOTEUR_CORS_ORIGINS", "") + auth_checks.append(_check( + "cors", "Origines CORS", "info", + cors or "Non définie — politique par défaut appliquée.", + "Pour le développement de l'IHM, ajouter http://localhost:5173.", + )) + groups.append({"id": "auth", "label": "Authentification", "checks": auth_checks}) + + # ── 2. Bases de données ─────────────────────────────────────────── + db_checks = [] + + runtime_path = os.path.join(_ENGINE_ROOT, "moteur_runtime.db") + sessions = _table_count(runtime_path, "sessions") + if sessions is None: + db_checks.append(_check( + "runtime_db", "Base d'exécution (moteur_runtime.db)", "error", + "Introuvable ou illisible. Sessions, tokens et métriques ne peuvent pas être enregistrés.", + "Elle est créée automatiquement au premier démarrage de gui_server.py.", + )) + else: + tokens = _table_count(runtime_path, "token_usage") or 0 + db_checks.append(_check( + "runtime_db", "Base d'exécution (moteur_runtime.db)", "ok", + f"{sessions} session(s), {tokens} enregistrement(s) de consommation.", + )) + + models_path = os.path.join(_ENGINE_ROOT, "models_registry.db") + models_n = _table_count(models_path, "models") + if models_n is None: + db_checks.append(_check( + "models_db", "Catalogue de modèles (models_registry.db)", "error", + "Introuvable. Le routage par tier n'a aucune source de vérité.", + "Lancer : python seed_models_db.py", + )) + elif models_n == 0: + db_checks.append(_check( + "models_db", "Catalogue de modèles (models_registry.db)", "warn", + "Base présente mais vide.", + "Lancer : python seed_models_db.py", + )) + else: + db_checks.append(_check( + "models_db", "Catalogue de modèles (models_registry.db)", "ok", + f"{models_n} modèle(s) au catalogue.", + )) + + memory_path = os.path.join(_ENGINE_ROOT, "memory.db") + if os.path.exists(memory_path): + size_mb = round(os.path.getsize(memory_path) / 1_048_576, 1) + db_checks.append(_check( + "memory_db", "Mémoire (memory.db)", "ok", + f"Présente ({size_mb} Mo) — faits vérifiés, épisodes, graphe de connaissances.", + )) + else: + db_checks.append(_check( + "memory_db", "Mémoire (memory.db)", "warn", + "Absente : le moteur fonctionne, mais sans mémoire longue.", + "Lancer : python seed_memory_db.py", + )) + + chroma_path = os.path.join(_ENGINE_ROOT, "chroma_db") + if os.path.isdir(chroma_path) and os.path.exists(os.path.join(chroma_path, "chroma.sqlite3")): + db_checks.append(_check( + "chroma", "Index vectoriel (chroma_db)", "ok", + "Présent — la recherche sémantique (RAG) est opérationnelle.", + )) + else: + db_checks.append(_check( + "chroma", "Index vectoriel (chroma_db)", "warn", + "Absent : la recherche sémantique se rabat sur la recherche lexicale.", + "Lancer : python populate_embeddings.py", + )) + groups.append({"id": "databases", "label": "Bases de données", "checks": db_checks}) + + # ── 3. Configuration ────────────────────────────────────────────── + cfg_checks = [] + config_path = os.path.join(_ENGINE_ROOT, "config.json") + try: + import json + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + tiers = config.get("tiers", {}) + empty_tiers = [name for name, models in tiers.items() if not models] + if empty_tiers: + cfg_checks.append(_check( + "config", "config.json", "warn", + f"Lisible, mais tier(s) vide(s) : {', '.join(empty_tiers)}.", + "Compléter dans Configuration → Composition des tiers.", + )) + else: + cfg_checks.append(_check( + "config", "config.json", "ok", + f"Lisible — {len(tiers)} tier(s) définis, planner={config.get('planner_model', '?')}.", + )) + except Exception as e: + config = {} + cfg_checks.append(_check( + "config", "config.json", "error", + f"Illisible : {e}", + "Vérifier la syntaxe JSON du fichier config.json.", + )) + + pa = config.get("persistent_agents", {}) + active = [n for n in ("daemon", "dreamer", "auditor") if pa.get(f"{n}_enabled")] + cfg_checks.append(_check( + "persistent_agents", "Agents autonomes", "info", + f"Actifs : {', '.join(active) if active else 'aucun'}.", + )) + groups.append({"id": "config", "label": "Configuration", "checks": cfg_checks}) + + # ── 4. Providers LLM ────────────────────────────────────────────── + provider_checks = [] + configured = [(env, label) for env, label in _PROVIDER_KEYS if os.environ.get(env)] + missing = [label for env, label in _PROVIDER_KEYS if not os.environ.get(env)] + + if configured: + provider_checks.append(_check( + "provider_keys", "Clés providers", "ok", + f"{len(configured)} configurée(s) : {', '.join(label for _, label in configured)}.", + )) + else: + provider_checks.append(_check( + "provider_keys", "Clés providers", "error", + "Aucune clé de provider LLM : le moteur ne peut appeler aucun modèle distant.", + "Renseigner au moins une clé dans le .env (GEMINI_API_KEY est gratuite).", + )) + if missing: + provider_checks.append(_check( + "provider_missing", "Providers non configurés", "info", + f"{len(missing)} sans clé : {', '.join(missing)}. Ces providers sont simplement inactifs.", + )) + + # Nombre de providers réellement câblés dans le gateway. + try: + from core.llm_gateway import LLMGateway + wired = len(LLMGateway().providers) + provider_checks.append(_check( + "gateway", "Passerelle LLM", "ok" if wired else "error", + f"{wired} provider(s) instanciés dans le gateway." + if wired else "Aucun provider instancié.", + None if wired else "Vérifier les clés du .env et les logs de démarrage.", + )) + except Exception as e: + provider_checks.append(_check( + "gateway", "Passerelle LLM", "error", + f"Instanciation impossible : {e}", + "Consulter les logs du serveur au démarrage.", + )) + groups.append({"id": "providers", "label": "Providers LLM", "checks": provider_checks}) + + # ── 5. Home Assistant ───────────────────────────────────────────── + ha_checks = [] + ha_token = os.environ.get("HASS_TOKEN") or os.environ.get("HA_TOKEN") + ha_url = os.environ.get("HASS_URL") or os.environ.get("HA_URL") + if not ha_token: + ha_checks.append(_check( + "ha_token", "Token Home Assistant", "warn", + "Absent : les commandes domotiques et l'assistant vocal sont hors service.", + "Créer un token longue durée dans HA (profil utilisateur) et le poser dans HASS_TOKEN.", + )) + else: + ha_checks.append(_check("ha_token", "Token Home Assistant", "ok", "Configuré.")) + + if ha_url: + try: + from api.routes.ha import ha_health + health = await ha_health() + if health.get("reachable"): + ha_checks.append(_check( + "ha_reachable", "Connexion Home Assistant", "ok", + f"Joignable en {health.get('latency_ms')} ms — " + f"{health.get('entity_count')} entité(s), version {health.get('version')}.", + )) + else: + err = health.get("error") or "cause inconnue" + fix = "Vérifier HASS_URL et la joignabilité réseau." + if "CERTIFICATE_VERIFY_FAILED" in err: + fix = ("Certificat TLS non valide pour cette adresse : renseigner HA_CA_BUNDLE, " + "ou utiliser le nom d'hôte couvert par le certificat, ou en dernier " + "recours HA_VERIFY_TLS=false sur un LAN de confiance.") + elif "401" in err: + fix = "Token refusé : en régénérer un dans Home Assistant." + ha_checks.append(_check( + "ha_reachable", "Connexion Home Assistant", "error", err, fix, + )) + except Exception as e: + ha_checks.append(_check( + "ha_reachable", "Connexion Home Assistant", "error", str(e), + "Vérifier HASS_URL et la configuration TLS.", + )) + else: + ha_checks.append(_check( + "ha_url", "URL Home Assistant", "warn", + "HASS_URL non définie — valeur par défaut utilisée.", + "Définir HASS_URL dans le .env.", + )) + groups.append({"id": "homeassistant", "label": "Home Assistant", "checks": ha_checks}) + + # ── Synthèse ────────────────────────────────────────────────────── + all_checks = [c for g in groups for c in g["checks"]] + errors = [c for c in all_checks if c["status"] == "error"] + warnings = [c for c in all_checks if c["status"] == "warn"] + + return { + "groups": groups, + "summary": { + "total": len(all_checks), + "ok": len([c for c in all_checks if c["status"] == "ok"]), + "warnings": len(warnings), + "errors": len(errors), + "operational": len(errors) == 0, + }, + } diff --git a/api/routes/vocal.py b/api/routes/vocal.py new file mode 100644 index 0000000..7dff61b --- /dev/null +++ b/api/routes/vocal.py @@ -0,0 +1,160 @@ +""" +api/routes/vocal.py — Routes d'observation de l'assistant vocal. + +Complète les routes vocales déjà présentes dans `api/routes/agents.py` +(`/api/vocal/audit`, `/api/vocal/abort`) par ce qui manquait à l'IHM pour +comprendre et régler le pipeline vocal : statistiques agrégées, conversations +et configuration effective. + +Tout est en LECTURE SEULE et provient de mesures réelles (`vocal_audit_log`, +`vocal_conversation_turns`, `vocal_jobs`) : aucune valeur n'est estimée. + +Auteur : Claude + Axel — 2026-07-28 +""" + +import logging +import os +import time + +from fastapi import APIRouter, Query + +from core.runtime_db import get_connection + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/vocal", tags=["Assistant vocal"]) + + +def _rows(query: str, params: tuple = ()) -> list[dict]: + """Requête de lecture tolérante : une table absente n'écroule pas la vue.""" + try: + import sqlite3 + conn = get_connection() + conn.row_factory = sqlite3.Row + try: + return [dict(r) for r in conn.execute(query, params).fetchall()] + finally: + conn.close() + except Exception as e: + logger.warning(f"[VOCAL] Lecture SQLite impossible : {e}") + return [] + + +@router.get("/stats") +async def vocal_stats(period: str = Query("7d", description="1h, 24h, 7d, 30d")): + """ + Statistiques réelles du pipeline vocal sur une fenêtre donnée. + + `vocal_audit_log` enregistre deux lignes par échange (phase `request` puis + `response`) : les latences ne sont donc calculées que sur les lignes + `response`, seules à porter une mesure. + """ + period_map = {"1h": 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400} + seconds = period_map.get(period, 7 * 86400) + since_iso = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - seconds)) + + totals = _rows( + "SELECT COUNT(*) AS n, " + "SUM(CASE WHEN phase = 'response' THEN 1 ELSE 0 END) AS responses, " + "SUM(CASE WHEN tts_enabled = 1 THEN 1 ELSE 0 END) AS tts_on " + "FROM vocal_audit_log WHERE created_at > ?", + (since_iso,), + ) + + latency = _rows( + "SELECT AVG(latency_ms) AS avg_ms, MIN(latency_ms) AS min_ms, MAX(latency_ms) AS max_ms " + "FROM vocal_audit_log WHERE created_at > ? AND phase = 'response' AND latency_ms IS NOT NULL", + (since_iso,), + ) + + by_mode = _rows( + "SELECT source_mode, COUNT(*) AS n, AVG(latency_ms) AS avg_ms " + "FROM vocal_audit_log WHERE created_at > ? AND phase = 'response' " + "GROUP BY source_mode ORDER BY n DESC", + (since_iso,), + ) + + by_device = _rows( + "SELECT COALESCE(device_id, 'inconnu') AS device_id, COUNT(*) AS n " + "FROM vocal_audit_log WHERE created_at > ? GROUP BY device_id ORDER BY n DESC", + (since_iso,), + ) + + by_routing = _rows( + "SELECT COALESCE(routing_type, 'inconnu') AS routing_type, COUNT(*) AS n " + "FROM vocal_audit_log WHERE created_at > ? AND phase = 'response' " + "GROUP BY routing_type ORDER BY n DESC", + (since_iso,), + ) + + t = totals[0] if totals else {} + lat = latency[0] if latency else {} + + return { + "period": period, + "total_events": t.get("n") or 0, + "total_exchanges": t.get("responses") or 0, + "tts_enabled_events": t.get("tts_on") or 0, + "latency_ms": { + "avg": round(lat["avg_ms"], 1) if lat.get("avg_ms") is not None else None, + "min": round(lat["min_ms"], 1) if lat.get("min_ms") is not None else None, + "max": round(lat["max_ms"], 1) if lat.get("max_ms") is not None else None, + }, + "by_mode": by_mode, + "by_device": by_device, + "by_routing": by_routing, + } + + +@router.get("/conversations") +async def vocal_conversations(limit: int = Query(20, ge=1, le=100)): + """Derniers tours de conversation vocale (mode Discussion).""" + turns = _rows( + "SELECT conversation_id, turn_index, role, content, source_mode, device_id, created_at " + "FROM vocal_conversation_turns ORDER BY created_at DESC LIMIT ?", + (limit,), + ) + return {"count": len(turns), "turns": turns} + + +@router.get("/jobs") +async def vocal_jobs(limit: int = Query(20, ge=1, le=100)): + """Jobs vocaux asynchrones (mode Discussion long).""" + jobs = _rows( + "SELECT job_id, intent, status, user_prompt, conversation_id, device_id, " + "result_text, error_message, created_at, updated_at " + "FROM vocal_jobs ORDER BY created_at DESC LIMIT ?", + (limit,), + ) + return {"count": len(jobs), "jobs": jobs} + + +@router.get("/config") +async def vocal_config(): + """ + Configuration effective du pipeline vocal, telle que le moteur la voit. + + Regroupe ce qui est réellement lu à l'exécution : le modèle de l'agent HA + (config.json) et la présence des clés qui conditionnent la synthèse vocale + cloud. La présence d'une clé est rapportée, jamais sa valeur. + """ + config: dict = {} + try: + import json + engine_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + with open(os.path.join(engine_root, "config.json"), encoding="utf-8") as f: + config = json.load(f) + except Exception as e: + logger.warning(f"[VOCAL] config.json illisible : {e}") + + return { + "ha_model": config.get("ha_model"), + "executor_model": config.get("executor_model"), + "semantic_cache": config.get("semantic_cache", {}), + "secrets_present": { + "HASS_TOKEN": bool(os.environ.get("HASS_TOKEN") or os.environ.get("HA_TOKEN")), + "GEMINI_API_KEY": bool(os.environ.get("GEMINI_API_KEY")), + "GEMINI_PAYANT_API_KEY": bool(os.environ.get("GEMINI_PAYANT_API_KEY")), + }, + "tts_cloud_available": bool(os.environ.get("GEMINI_PAYANT_API_KEY") or os.environ.get("GEMINI_API_KEY")), + } diff --git a/core/budget_guard.py b/core/budget_guard.py index b9a9a42..310d2cf 100644 --- a/core/budget_guard.py +++ b/core/budget_guard.py @@ -7,23 +7,77 @@ Optimisé pour Windows, ultra-fiable et thread-safe via db_lock_context. """ -import os +import asyncio import json -import time import logging +import os import sqlite3 -import asyncio -import aiohttp -from typing import Optional, Dict, Any, Tuple +import time from pathlib import Path +from typing import Any + +import aiohttp + +from core.backlog_db import db_read_lock_context, db_write_lock_context # Imports requis du tab5-engine -from core.runtime_db import get_db_path, get_connection -from core.backlog_db import db_write_lock_context, db_read_lock_context +from core.runtime_db import get_connection, get_db_path # Configuration du logging logger = logging.getLogger("Antigravity.BudgetGuard") +# Cache module-level (PAS un attribut d'instance) : BudgetGuard() est +# ré-instancié à chaque appel un peu partout (dreamer_agent.py, tests...), +# donc un cache par instance ne servirait à rien. Évite de rappeler l'API +# de solde DeepSeek à chaque décision de cascade (potentiellement plusieurs +# fois par seconde). +_deepseek_balance_cache: dict[str, Any] = {"value": None, "checked_at": 0.0} +_DEEPSEEK_BALANCE_CACHE_TTL = 600.0 # 10 min + + +async def _get_cached_deepseek_balance_usd() -> float | None: + """Solde réel DeepSeek (core.provider_balances), rafraîchi au plus toutes les 10 min.""" + now = time.time() + if now - _deepseek_balance_cache["checked_at"] < _DEEPSEEK_BALANCE_CACHE_TTL: + return _deepseek_balance_cache["value"] + from core.provider_balances import fetch_deepseek_balance_usd + balance = await fetch_deepseek_balance_usd() + _deepseek_balance_cache["value"] = balance + _deepseek_balance_cache["checked_at"] = now + return balance + + +# Cooldown module-level après échec d'un provider (ex: CLI Gemini/Claude non +# trustée dans l'environnement) — évite qu'une boucle de drainage (plusieurs +# tâches par cycle) retente en boucle un provider cassé sur chaque tâche du +# même cycle, ce qui brûlerait le temps du cycle sur des échecs répétés au +# lieu d'avancer sur le backlog via les providers suivants de la cascade. +_provider_cooldown: dict[str, float] = {} +_PROVIDER_COOLDOWN_SECONDS = 1800.0 # 30 min + + +def mark_provider_failed(provider: str) -> None: + """ + Place un provider en cooldown après un échec d'exécution (appelé par + agents/dreamer_agent.py sur un pipeline_err). get_available_provider() + ne le re-proposera pas avant _PROVIDER_COOLDOWN_SECONDS. + """ + _provider_cooldown[provider] = time.time() + logger.warning(f"[BudgetGuard] Provider '{provider}' mis en cooldown ({_PROVIDER_COOLDOWN_SECONDS/60:.0f} min) après échec.") + + +def _in_cooldown(provider: str) -> bool: + ts = _provider_cooldown.get(provider, 0.0) + return (time.time() - ts) < _PROVIDER_COOLDOWN_SECONDS + + +# Index de rotation round-robin module-level (BudgetGuard() est réinstancié +# à chaque appel, cf. commentaire sur _deepseek_balance_cache) — fait tourner +# les API cloud gratuites disponibles plutôt que de toujours privilégier la +# même (retour Axel 07/07 : Ollama gagnait systématiquement, empêchant +# Gemini/Cerebras/Cohere/Mistral d'être jamais réellement exercés). +_free_api_rotation_index = 0 + class BudgetGuard: """ @@ -44,7 +98,7 @@ def __init__(self) -> None: self.config = self._load_config() self._initialized = False - def _load_config(self) -> Dict[str, Any]: + def _load_config(self) -> dict[str, Any]: """ Charge la configuration depuis le fichier config.json. Retourne des valeurs par défaut robustes en cas d'absence ou d'erreur. @@ -52,22 +106,26 @@ def _load_config(self) -> Dict[str, Any]: defaults = { "gemini_free_tokens_per_hour": 1_000_000, "deepseek_free_requests_per_day": 200, - "daily_budget_usd": 0.50 + "daily_budget_usd": 0.50, + "auditor_weekly_budget_usd": 1.00, + "total_daily_budget_usd": 1.00 } - + if not self.config_path.exists(): logger.warning(f"Fichier de configuration introuvable à {self.config_path}. Utilisation des valeurs par défaut.") return defaults try: - with open(self.config_path, "r", encoding="utf-8") as f: + with open(self.config_path, encoding="utf-8") as f: data = json.load(f) bg_config = data.get("budget_guard", {}) - + return { "gemini_free_tokens_per_hour": bg_config.get("gemini_free_tokens_per_hour", defaults["gemini_free_tokens_per_hour"]), "deepseek_free_requests_per_day": bg_config.get("deepseek_free_requests_per_day", defaults["deepseek_free_requests_per_day"]), - "daily_budget_usd": bg_config.get("daily_budget_usd", defaults["daily_budget_usd"]) + "daily_budget_usd": bg_config.get("daily_budget_usd", defaults["daily_budget_usd"]), + "total_daily_budget_usd": bg_config.get("total_daily_budget_usd", defaults["total_daily_budget_usd"]), + "auditor_weekly_budget_usd": bg_config.get("auditor_weekly_budget_usd", defaults["auditor_weekly_budget_usd"]) } except Exception as e: logger.error(f"Erreur lors de la lecture de la configuration : {e}. Utilisation des valeurs par défaut.") @@ -123,57 +181,96 @@ async def _check_ollama_availability(self) -> bool: except Exception: return False - async def get_available_provider(self) -> Optional[str]: + def _check_gemini_cli_availability(self) -> bool: + """ + Vérifie la présence locale du binaire Antigravity IDE (utilisé par + GeminiCLIProvider, core/llm/providers/gemini.py) — pas une vérification + de quota (l'abonnement Ultra est quasi-illimité), juste que le CLI existe + sur cette machine. N'atteste PAS que le dialogue de confiance du + workspace est accepté : un échec réel à l'exécution est absorbé par + mark_provider_failed() (cooldown), pas par ce test préalable. + """ + import shutil + user_home = os.path.expanduser("~") + candidates = [ + os.path.join(user_home, "AppData", "Local", "Programs", "Antigravity IDE", "bin", "antigravity-ide.cmd"), + os.path.join(user_home, "AppData", "Local", "Programs", "Antigravity", "bin", "antigravity.cmd"), + ] + if any(os.path.exists(p) for p in candidates): + return True + return any(shutil.which(name) for name in ("antigravity-ide", "antigravity-ide.cmd", "antigravity", "antigravity.exe")) + + def _check_claude_cli_availability(self) -> bool: + """ + Vérifie la présence locale du binaire `claude` (utilisé par + ClaudeCLIProvider, core/llm/providers/deepseek.py) — même limite que + _check_gemini_cli_availability() : présence du binaire seulement, pas + du dialogue de confiance du workspace. + """ + import shutil + return shutil.which("claude") is not None + + async def get_available_provider(self) -> str | None: """ - Détermine le provider LLM optimal à utiliser selon la cascade de priorité : - 1. ollama (local, gratuit, illimité — runtime local préféré) - 2. lmstudio (local, gratuit, illimité — secours) - 3. gemini-free (quota horaire de tokens) - 4. deepseek-free (quota journalier de requêtes) - 5. anthropic-claude-haiku (budget financier quotidien) - 6. None (si tous les quotas/budgets sont épuisés) + Détermine le provider d'EXÉCUTION (executor) optimal (07/07, révisé + suite à retour Axel : la 1ère version mettait Ollama en tête, or il + tourne en permanence sur le Deck → il gagnait TOUJOURS, empêchant les + API cloud gratuites listées (Gemini/Cerebras/Cohere/Mistral) d'être + jamais utilisées. Nouvelle logique : + + 1. Rotation round-robin parmi les API cloud gratuites/abonnements + disponibles (gemini-cli-abo, claude-cli-abo, cerebras-free, + dashscope-coding, cohere-free, mistral-free, gemini-free) — + "lisse" l'usage entre providers au lieu qu'un seul domine + systématiquement. + 2. Ollama/LM Studio en FILET DE SÉCURITÉ (local, gratuit, mais après + les API cloud — domotique-qwen7b:q4 sur Ollama est un modèle 7B + spécialisé HA, pas idéal pour du code généraliste, donc réservé au + cas où rien d'autre n'est disponible). + 3. deepseek-free (quota + solde réel), puis anthropic-claude-haiku + (payant, budget dédié) — coupés si total_daily_budget_usd atteint. + 4. None si tout est épuisé. + + Note : ceci reste une cascade de coût, pas un choix de modèle "adapté + à la tâche" (ça, c'est core/provider_scorer.py::ProviderScorer, utilisé + par le moteur interactif via LLMGateway.get_provider_for_tier — pas + branché sur DreamCoder pour l'instant, chantier séparé). """ if not self._initialized: await self.initialize() - # 1. Test d'Ollama (Local, runtime préféré) - if await self._check_ollama_availability(): - return "ollama" - - # 2. Test de LM Studio (Local, secours) - if await self._check_lmstudio_availability(): - return "lmstudio" - now = time.time() one_hour_ago = now - 3600.0 twenty_four_hours_ago = now - 86400.0 async with db_read_lock_context(): - def query_db_status() -> Tuple[int, int, float]: + def query_db_status() -> tuple[int, int, float]: conn = sqlite3.connect(self.db_path) try: cursor = conn.cursor() - + # Gemini : Somme des tokens utilisés dans la dernière heure cursor.execute(""" - SELECT COALESCE(SUM(tokens_used), 0) - FROM billing_history + SELECT COALESCE(SUM(tokens_used), 0) + FROM billing_history WHERE provider = 'gemini-free' AND timestamp > ? """, (one_hour_ago,)) gemini_tokens = int(cursor.fetchone()[0]) # DeepSeek : Nombre de requêtes dans les dernières 24 heures cursor.execute(""" - SELECT COUNT(*) - FROM billing_history + SELECT COUNT(*) + FROM billing_history WHERE provider = 'deepseek-free' AND timestamp > ? """, (twenty_four_hours_ago,)) deepseek_requests = int(cursor.fetchone()[0]) # Global/Payant : Somme des coûts USD dans les dernières 24 heures + # (toutes sources confondues — sert de plafond quotidien COMBINÉ, + # pas seulement pour Claude Haiku, cf. total_daily_budget_usd). cursor.execute(""" - SELECT COALESCE(SUM(cost_usd), 0.0) - FROM billing_history + SELECT COALESCE(SUM(cost_usd), 0.0) + FROM billing_history WHERE timestamp > ? """, (twenty_four_hours_ago,)) global_cost = float(cursor.fetchone()[0]) @@ -184,15 +281,62 @@ def query_db_status() -> Tuple[int, int, float]: gemini_tokens, deepseek_requests, global_cost = await asyncio.to_thread(query_db_status) - # 2. Test de Gemini Free - if gemini_tokens < self.config["gemini_free_tokens_per_hour"]: - return "gemini-free" + # 1. Rotation round-robin parmi les API cloud gratuites/abonnements + # réellement disponibles (clé présente / binaire trouvé / pas en + # cooldown / quota non dépassé pour gemini-free). + cloud_candidates: list[str] = [] + if not _in_cooldown("gemini-cli-abo") and self._check_gemini_cli_availability(): + cloud_candidates.append("gemini-cli-abo") + if not _in_cooldown("claude-cli-abo") and self._check_claude_cli_availability(): + cloud_candidates.append("claude-cli-abo") + if not _in_cooldown("cerebras-free") and os.getenv("CEREBRAS_API_KEY"): + cloud_candidates.append("cerebras-free") + # Coding Plan Alibaba (Lite) — forfait requêtes, amorti comme un abo. + if not _in_cooldown("dashscope-coding") and ( + os.getenv("DASHSCOPE_API_KEY") or os.getenv("BAILIAN_CODING_PLAN_API_KEY") + ): + cloud_candidates.append("dashscope-coding") + if not _in_cooldown("cohere-free") and os.getenv("COHERE_API_KEY"): + cloud_candidates.append("cohere-free") + if not _in_cooldown("mistral-free") and os.getenv("MISTRAL_API_KEY"): + cloud_candidates.append("mistral-free") + if not _in_cooldown("gemini-free") and gemini_tokens < self.config["gemini_free_tokens_per_hour"]: + cloud_candidates.append("gemini-free") + + if cloud_candidates: + global _free_api_rotation_index + chosen = cloud_candidates[_free_api_rotation_index % len(cloud_candidates)] + _free_api_rotation_index += 1 + return chosen + + # 2. Ollama/LM Studio — filet de sécurité local si aucune API cloud + # gratuite/abonnement n'est disponible (toutes en cooldown/absentes). + if await self._check_ollama_availability(): + return "ollama" + if await self._check_lmstudio_availability(): + return "lmstudio" - # 3. Test de DeepSeek Free + total_cap_reached = global_cost >= self.config["total_daily_budget_usd"] + if total_cap_reached: + logger.warning( + f"[BudgetGuard] Plafond quotidien combiné atteint (${global_cost:.4f}/" + f"{self.config['total_daily_budget_usd']} USD) — DeepSeek/Claude-Haiku coupés, " + "gratuit/abonnement uniquement pour le reste de la journée." + ) + return None + + # 3. DeepSeek Free — quota de requêtes ET solde réel (API officielle + # DeepSeek, cache 10 min). Le comptage de requêtes seul ne dit rien si + # le compte est simplement à sec ; on vérifie les deux. if deepseek_requests < self.config["deepseek_free_requests_per_day"]: - return "deepseek-free" - - # 4. Test d'Anthropic Claude Haiku (Payant sous contrôle de budget) + balance = await _get_cached_deepseek_balance_usd() + if balance is None or balance > 0.01: + return "deepseek-free" + logger.warning( + f"[BudgetGuard] DeepSeek : quota requêtes OK mais solde réel quasi nul (${balance:.4f}) — provider sauté." + ) + + # 4. Anthropic Claude Haiku (payant sous contrôle de budget dédié) if global_cost < self.config["daily_budget_usd"]: return "anthropic-claude-haiku" @@ -200,16 +344,22 @@ def query_db_status() -> Tuple[int, int, float]: logger.warning("Alerte critique : Tous les quotas et budgets LLM sont épuisés !") return None - async def record_usage(self, provider: str, tokens: int, cost: float, model: str, window_type: str = "daily") -> None: + async def record_usage(self, provider: str, tokens: int, cost: float, model: str, + window_type: str = "daily", sync_source: str = "dreamcoder") -> None: """ Enregistre la consommation d'un appel LLM dans l'historique de facturation. Assure la rétrocompatibilité avec l'ancien schéma de données. + + :param sync_source: Origine de l'appel (défaut 'dreamcoder' pour préserver le + comportement existant). L'auditeur autonome (core/auditor_agent.py) passe + 'auditor' pour que ses dépenses soient comptabilisées séparément via + get_scoped_spend_usd(), sans impacter le budget quotidien global de DreamCoder. """ if not self._initialized: await self.initialize() timestamp = time.time() - + async with db_write_lock_context(): def insert_usage(): conn = sqlite3.connect(self.db_path) @@ -226,7 +376,7 @@ def insert_usage(): "tokens_and_cost", cost, "USD", - "dreamcoder", + sync_source, model, tokens, cost, @@ -243,7 +393,38 @@ def insert_usage(): await asyncio.to_thread(insert_usage) - async def get_quota_summary(self) -> Dict[str, Any]: + async def get_scoped_spend_usd(self, sync_source: str, since_seconds: float) -> float: + """ + Coût cumulé (USD) attribué à une source d'appel donnée (ex: 'auditor') + sur une fenêtre glissante — utilisé pour le plafond hebdomadaire de + l'auditeur autonome, indépendant du budget quotidien global de DreamCoder. + + :param sync_source: Origine à filtrer (colonne billing_history.sync_source). + :param since_seconds: Taille de la fenêtre glissante en secondes (ex: 7*86400). + :return: Somme des coûts USD sur la fenêtre, 0.0 si aucune dépense. + """ + if not self._initialized: + await self.initialize() + + cutoff = time.time() - since_seconds + + async with db_read_lock_context(): + def query_scoped_spend() -> float: + conn = sqlite3.connect(self.db_path) + try: + cursor = conn.cursor() + cursor.execute(""" + SELECT COALESCE(SUM(cost_usd), 0.0) + FROM billing_history + WHERE sync_source = ? AND timestamp > ? + """, (sync_source, cutoff)) + return float(cursor.fetchone()[0]) + finally: + conn.close() + + return await asyncio.to_thread(query_scoped_spend) + + async def get_quota_summary(self) -> dict[str, Any]: """ Retourne un état complet de l'utilisation des quotas et budgets pour l'IHM de supervision. """ @@ -257,11 +438,11 @@ async def get_quota_summary(self) -> Dict[str, Any]: twenty_four_hours_ago = now - 86400.0 async with db_read_lock_context(): - def fetch_summary_data() -> Tuple[int, int, float]: + def fetch_summary_data() -> tuple[int, int, float]: conn = sqlite3.connect(self.db_path) try: cursor = conn.cursor() - + cursor.execute(""" SELECT COALESCE(SUM(tokens_used), 0) FROM billing_history @@ -332,4 +513,4 @@ def fetch_summary_data() -> Tuple[int, int, float]: "unit": "USD" } } - } \ No newline at end of file + } diff --git a/core/ha_tls.py b/core/ha_tls.py index 97413fe..d44b221 100644 --- a/core/ha_tls.py +++ b/core/ha_tls.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ core/ha_tls.py — Politique TLS centralisée pour les connexions Home Assistant (P0-1.5). @@ -10,26 +9,69 @@ si fourni — recommandé pour un certificat auto-signé : pointer le .pem/.crt). - HA_VERIFY_TLS=false → vérification DÉSACTIVÉE (certif auto-signé sans CA fournie). Tolérable sur un LAN de confiance, à éviter sinon. Log un warning. + - HA_TLS_SERVER_HOSTNAME → nom d'hôte à vérifier dans le certificat, quand + `HASS_URL` vise une IP locale mais que le certificat ne couvre qu'un nom DNS + (cas Freebox : certificat Let's Encrypt `axellum.freeboxos.fr` servi sur + ${HA_HOST:-192.168.1.x}:8123). Équivalent du `--resolve` de curl : on joint l'IP locale + (rapide, sans dépendance WAN) tout en validant chaîne ET nom du certificat. ⚠️ DÉPLOIEMENT : si Home Assistant est en HTTPS avec un certificat auto-signé, définir soit `HA_CA_BUNDLE=/chemin/vers/ca.pem` (préféré), soit `HA_VERIFY_TLS=false`, sinon les appels HA échoueront (échec de validation du certificat). """ +import logging import os import ssl -import logging logger = logging.getLogger(__name__) _FALSY = {"0", "false", "no", "off", "non"} +class _PinnedHostnameContext(ssl.SSLContext): + """Contexte SSL qui vérifie le certificat contre un nom d'hôte imposé. + + `HASS_URL` pointe une IP (${HA_HOST:-192.168.1.x}) alors que le certificat ne couvre + qu'un nom DNS ; sans cela la vérification échoue sur « IP address mismatch ». + Le nom présenté en SNI et vérifié est donc forcé ici, pour tous les appelants + d'un coup — aucun site d'appel n'a à passer `server_hostname`, y compris + `aiohttp.TCPConnector` qui ne l'accepte pas. + + La vérification reste ENTIÈRE : chaîne de confiance, date, et nom d'hôte. Un + certificat qui ne couvre pas `_pinned_hostname` est rejeté (« Hostname + mismatch »), donc la valeur doit correspondre au certificat réellement servi. + """ + + _pinned_hostname = "" + + def wrap_bio(self, incoming, outgoing, server_side=False, + server_hostname=None, session=None): + # Chemin utilisé par asyncio/aiohttp (TLS sur BIO mémoire). + return super().wrap_bio(incoming, outgoing, server_side=server_side, + server_hostname=self._pinned_hostname, session=session) + + def wrap_socket(self, sock, server_side=False, do_handshake_on_connect=True, + suppress_ragged_eofs=True, server_hostname=None, session=None): + # Chemin utilisé par les clients synchrones (requests, http.client). + return super().wrap_socket( + sock, server_side=server_side, + do_handshake_on_connect=do_handshake_on_connect, + suppress_ragged_eofs=suppress_ragged_eofs, + server_hostname=self._pinned_hostname, session=session, + ) + + def ha_tls_verification_enabled() -> bool: """True si la vérification TLS des connexions HA est active (défaut : True).""" return os.environ.get("HA_VERIFY_TLS", "true").strip().lower() not in _FALSY +def ha_tls_pinned_hostname() -> str: + """Nom d'hôte imposé pour la vérification du certificat ("" si aucun).""" + return os.environ.get("HA_TLS_SERVER_HOSTNAME", "").strip() + + def ha_ssl_context() -> ssl.SSLContext: """Contexte SSL pour les connexions Home Assistant. @@ -40,8 +82,19 @@ def ha_ssl_context() -> ssl.SSLContext: ca_bundle = os.environ.get("HA_CA_BUNDLE", "").strip() if ca_bundle and os.path.exists(ca_bundle): logger.debug("[HA-TLS] Vérification TLS via CA dédiée : %s", ca_bundle) - return ssl.create_default_context(cafile=ca_bundle) - return ssl.create_default_context() + ctx = ssl.create_default_context(cafile=ca_bundle) + else: + ctx = ssl.create_default_context() + + pinned = ha_tls_pinned_hostname() + if pinned: + # `create_default_context()` porte les réglages durcis de la stdlib + # (versions min, ciphers) : on le conserve et on ne substitue que la + # classe, plutôt que de reconstruire un contexte moins bien réglé. + ctx.__class__ = _PinnedHostnameContext + ctx._pinned_hostname = pinned + logger.debug("[HA-TLS] Nom d'hôte vérifié imposé : %s", pinned) + return ctx # Opt-out explicite (HA_VERIFY_TLS=false) — seule désactivation TLS du projet. logger.warning( @@ -53,3 +106,40 @@ def ha_ssl_context() -> ssl.SSLContext: ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE return ctx + + +def ha_requests_verify() -> bool | str: + """Valeur `verify=` pour `requests` : False, chemin de CA, ou True.""" + if not ha_tls_verification_enabled(): + return False + ca_bundle = os.environ.get("HA_CA_BUNDLE", "").strip() + if ca_bundle and os.path.exists(ca_bundle): + return ca_bundle + return True + + +def ha_requests_session(): + """Session `requests` appliquant la politique TLS HA. + + Pendant synchrone de `ha_ssl_context()` : `verify=` ne sait pas exprimer un + nom d'hôte imposé, donc le contexte est monté via un adaptateur quand + HA_TLS_SERVER_HOSTNAME est défini. + """ + import requests + + session = requests.Session() + session.verify = ha_requests_verify() + + if ha_tls_verification_enabled() and ha_tls_pinned_hostname(): + from requests.adapters import HTTPAdapter + + context = ha_ssl_context() + + class _PinnedAdapter(HTTPAdapter): + def init_poolmanager(self, *args, **kwargs): + kwargs["ssl_context"] = context + return super().init_poolmanager(*args, **kwargs) + + session.mount("https://", _PinnedAdapter()) + + return session diff --git a/core/llm_gateway.py b/core/llm_gateway.py index eb20746..62085cd 100644 --- a/core/llm_gateway.py +++ b/core/llm_gateway.py @@ -108,12 +108,12 @@ def __init__(self, deepseek_key: str = None, gemini_key: str = None): "Ajoutez COHERE_API_KEY=... dans moteur_agents/.env" ) - cerebras_key = os.environ.get("CEREBRAS_API_KEY") - if not cerebras_key: + cerebras_free_key = os.environ.get("CEREBRAS_API_KEY") + cerebras_paid_key = os.environ.get("CEREBRAS_PAYANT_API_KEY") + if not cerebras_free_key and not cerebras_paid_key: logger.info( - "[LLMGateway] CEREBRAS_API_KEY absente du .env — " - "les providers Cerebras seront désactivés. " - "Ajoutez CEREBRAS_API_KEY=... dans moteur_agents/.env" + "[LLMGateway] CEREBRAS_API_KEY / CEREBRAS_PAYANT_API_KEY absente du .env — " + "les providers Cerebras seront désactivés." ) openrouter_key = os.environ.get("OPENROUTER_API_KEY") @@ -164,6 +164,19 @@ def __init__(self, deepseek_key: str = None, gemini_key: str = None): "Ajoutez ZHIPU_API_KEY=... dans moteur_agents/.env" ) + # Coding Plan Alibaba (Lite/Pro) — clé sk-sp-* uniquement. + # Alias BAILIAN_CODING_PLAN_API_KEY accepté (outils Qwen Code / OpenClaw). + dashscope_key = ( + os.environ.get("DASHSCOPE_API_KEY") + or os.environ.get("BAILIAN_CODING_PLAN_API_KEY") + ) + if not dashscope_key: + logger.info( + "[LLMGateway] DASHSCOPE_API_KEY absente du .env — " + "le Coding Plan Alibaba (DashScope) sera désactivé. " + "Ajoutez DASHSCOPE_API_KEY=sk-sp-... dans moteur_agents/.env" + ) + anthropic_key = os.environ.get("ANTHROPIC_API_KEY") if not anthropic_key: logger.info( @@ -182,9 +195,19 @@ def __init__(self, deepseek_key: str = None, gemini_key: str = None): def _make_compat(provider_id: str, model: str, api_key: str, timeout: tuple = None) -> OpenAICompatibleProvider: """Crée un provider OpenAI-compatible à partir du registre centralisé.""" config = OPENAI_COMPAT_PROVIDERS.get(provider_id, {}) + base_url = config.get("base_url", "") + # Surcharge d'endpoint Coding Plan (CN vs Intl) via env. + if provider_id == "dashscope": + override = os.environ.get("DASHSCOPE_BASE_URL", "").rstrip("/") + if override: + base_url = ( + override + if override.endswith("/chat/completions") + else f"{override}/chat/completions" + ) return OpenAICompatibleProvider( provider_name=provider_id.capitalize(), - base_url=config.get("base_url", ""), + base_url=base_url, api_key=api_key, model=model, extra_headers=config.get("extra_headers"), @@ -225,13 +248,23 @@ def _make_compat(provider_id: str, model: str, api_key: str, timeout: tuple = No } # --- Cerebras --- + # BudgetGuard « cerebras-free » → gpt-oss-120b : DOIT consommer la clé free + # (sinon la rotation gratuite facture la clé payante). Les modèles « paid » + # (gemma / zai) préfèrent CEREBRAS_PAYANT_API_KEY, free en secours. _cerebras = {} - if cerebras_key: - _cerebras = { - "cerebras": _make_compat("cerebras", "gpt-oss-120b", cerebras_key), - "gpt-oss-120b": _make_compat("cerebras", "gpt-oss-120b", cerebras_key), - "zai-glm-4.7": _make_compat("cerebras", "zai-glm-4.7", cerebras_key), - } + key_free_path = cerebras_free_key or cerebras_paid_key + key_paid_path = cerebras_paid_key or cerebras_free_key + if key_free_path: + _cerebras.update({ + "cerebras": _make_compat("cerebras", "gpt-oss-120b", key_free_path), + "gpt-oss-120b": _make_compat("cerebras", "gpt-oss-120b", key_free_path), + }) + if key_paid_path: + _cerebras.update({ + "gemma-4-31b-cerebras": _make_compat("cerebras", "gemma-4-31b", key_paid_path), + "gemma-4-31b": _make_compat("cerebras", "gemma-4-31b", key_paid_path), + "zai-glm-4.7": _make_compat("cerebras", "zai-glm-4.7", key_paid_path), + }) # --- OpenRouter --- _openrouter = {} @@ -337,6 +370,39 @@ def _make_minimax(model: str) -> OpenAICompatibleProvider: "glm-4.5-air": _make_compat("zhipu", "glm-4.5-air", zhipu_key), } + # --- Alibaba DashScope Coding Plan (Lite) --- + # Allowlist exacte renvoyée par GET /v1/models (2026-07-26). + # IDs préfixés `dashscope/...` pour éviter les collisions avec local/zhipu/minimax + # (qwen3-coder-next, glm-5, glm-4.7, MiniMax-M2.5). + # ⚠️ CGU Alibaba : forfait prévu pour outils de coding interactifs ; usage + # backend/API automatisé hors périmètre officiel (risque de révocation). + _dashscope = {} + if dashscope_key: + _ds_models = [ + "qwen3.7-plus", + "qwen3.6-plus", + "qwen3.5-plus", + "qwen3-max-2026-01-23", + "qwen3-coder-next", + "qwen3-coder-plus", + "kimi-k2.5", + "glm-5", + "glm-4.7", + "MiniMax-M2.5", + ] + _dashscope = { + f"dashscope/{mid}": _make_compat("dashscope", mid, dashscope_key) + for mid in _ds_models + } + # Alias principal + raccourcis uniques (pas de collision catalogue) + _dashscope["dashscope"] = _make_compat("dashscope", "qwen3-coder-next", dashscope_key) + _dashscope["qwen3.7-plus"] = _dashscope["dashscope/qwen3.7-plus"] + _dashscope["qwen3.6-plus"] = _dashscope["dashscope/qwen3.6-plus"] + _dashscope["qwen3.5-plus"] = _dashscope["dashscope/qwen3.5-plus"] + _dashscope["qwen3-max-2026-01-23"] = _dashscope["dashscope/qwen3-max-2026-01-23"] + _dashscope["qwen3-coder-plus"] = _dashscope["dashscope/qwen3-coder-plus"] + _dashscope["kimi-k2.5"] = _dashscope["dashscope/kimi-k2.5"] + # --- Anthropic API directe (ANTHROPIC_API_KEY, indépendant du CLI Claude Pro) --- _anthropic_native = {} if anthropic_key: @@ -367,7 +433,7 @@ def _make_anthropic(model: str) -> AnthropicNativeProvider: "domotique-qwen7b:q4": _make_compat("ollama_local", "domotique-qwen7b:q4", "ollama"), "qwen2.5-coder:7b": _make_compat("ollama_local", "qwen2.5-coder:7b", "ollama"), "deepseek-r1:8b": _make_compat("ollama_local", "deepseek-r1:8b", "ollama"), - # Variante joignable en LAN (192.168.1.x) depuis le Deck — cf. commentaire + # Variante joignable en LAN (${OLLAMA_HOST:-192.168.1.x}) depuis le Deck — cf. commentaire # dans OPENAI_COMPAT_PROVIDERS["ollama_pc"]. Utilisée par le fast path vocal # (FAST_PATH_PROVIDERS) pour du local-first même quand le moteur tourne sur le Deck. # Timeout dédié (connect 2s, read 15s) — PAS la famille "lmstudio" (120s de read) : @@ -390,12 +456,13 @@ def _make_anthropic(model: str) -> AnthropicNativeProvider: **_deepinfra, **_github, **_zhipu, + **_dashscope, **_anthropic_native, **_ollama_local, "local": LMStudioProvider(), # === STEAM DECK EDGE AI (Ollama RDNA2) === - # Endpoint réseau local : http://192.168.1.x:11434 + # Endpoint réseau local : http://${ENGINE_HOST:-192.168.1.x}:11434 # Disponibilité vérifiée dynamiquement via ping_available() # Tiers recommandés : parsing_logs, yaml_format, resume_court "deck_ollama": OllamaDeckProvider(), # phi3:mini par défaut @@ -555,6 +622,11 @@ def get_access_map(self) -> dict[str, dict[str, Any]]: provider_type = "cohere" elif "cerebras" in name or "gpt-oss" in name: provider_type = "cerebras" + elif "dashscope" in name or name in ( + "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", + "qwen3-max-2026-01-23", "qwen3-coder-plus", "kimi-k2.5", + ): + provider_type = "dashscope" elif "openrouter" in name: provider_type = "openrouter" elif "minimax" in name: diff --git a/core/mcp_tools/homeassistant.py b/core/mcp_tools/homeassistant.py index c3ff17b..8b3f453 100644 --- a/core/mcp_tools/homeassistant.py +++ b/core/mcp_tools/homeassistant.py @@ -34,6 +34,7 @@ async def search_ha_entities( """ try: import asyncio + import requests # Récupérer les entités HA via l'API @@ -43,37 +44,40 @@ async def search_ha_entities( if not ha_token: return "❌ Variable HA_TOKEN non configurée dans .env. Impossible de contacter Home Assistant." + # [P0-1.5] passer par la session TLS centralisée, sinon HA en HTTPS échoue. + from core.ha_tls import ha_requests_session + # [T121] requests est synchrone → to_thread pour ne pas geler l'event loop. response = await asyncio.to_thread( - requests.get, + ha_requests_session().get, f"{ha_url}/api/states", headers={"Authorization": f"Bearer {ha_token}", "Content-Type": "application/json"}, timeout=10, ) - + if response.status_code != 200: return f"❌ HA API erreur {response.status_code}: {response.text[:200]}" - + entities = response.json() - + # Filtrage par domaine if domain: entities = [e for e in entities if e.get("entity_id", "").startswith(f"{domain}.")] - + # Filtrage par query (recherche dans entity_id et friendly_name) query_lower = query.lower() matched = [ - e for e in entities + e for e in entities if query_lower in e.get("entity_id", "").lower() or query_lower in e.get("attributes", {}).get("friendly_name", "").lower() ] - + if not matched: return f"🔍 Aucune entité trouvée pour '{query}'" + (f" dans le domaine '{domain}'" if domain else "") - + # Limiter à 30 résultats matched = matched[:30] - + lines = [f"🏠 **{len(matched)} entité(s)** trouvée(s) pour \"{query}\"" + (f" (domaine: {domain})" if domain else "") + "\n"] for e in matched: eid = e.get("entity_id", "?") @@ -82,7 +86,7 @@ async def search_ha_entities( unit = e.get("attributes", {}).get("unit_of_measurement", "") unit_str = f" {unit}" if unit else "" lines.append(f" - `{eid}` → **{name}** = `{state}{unit_str}`") - + return "\n".join(lines) except Exception as e: return f"❌ Erreur recherche HA : {e}" @@ -127,12 +131,12 @@ async def validate_config_format(file_path: str) -> str: # Vérifier que le chemin réel commence bien par workspace_root if not (resolved_path == workspace_root or resolved_path.startswith(workspace_root + os.sep)): return "❌ Erreur de sécurité : Accès refusé (Path Traversal détecté)." - + try: import asyncio # Exécuter la validation dans un thread séparé si elle contient des appels bloquants result = await asyncio.to_thread(linter.validate_file, resolved_path) - + if result["valid"]: return f"✅ CONFIGURATION VALIDE : {result['message']} (type: {result['type']})" else: @@ -167,9 +171,10 @@ async def execute_ha_action( service_data: JSON string des données du service (optionnel). Défaut: "{}". """ import asyncio - import requests import json as _json + import requests + ha_url = os.environ.get("HA_URL", os.environ.get("HASS_URL", "http://${HA_HOST:-192.168.1.x}:8123")) ha_token = os.environ.get("HA_TOKEN", os.environ.get("HASS_TOKEN", "")) @@ -187,7 +192,9 @@ async def execute_ha_action( # [P0-1.6] Valider les identifiants avant de les injecter dans l'URL de l'API HA # (anti-traversée de chemin / injection de segments via entrée LLM non maîtrisée). from core.validation import ( - is_valid_ha_entity_id, is_valid_ha_domain, is_valid_ha_service_name, + is_valid_ha_domain, + is_valid_ha_entity_id, + is_valid_ha_service_name, validate_service_data, ) if not is_valid_ha_entity_id(entity_id): @@ -216,9 +223,12 @@ async def execute_ha_action( endpoint = f"{ha_url}/api/services/{domain}/{svc_name}" try: + # [P0-1.5] passer par la session TLS centralisée, sinon HA en HTTPS échoue. + from core.ha_tls import ha_requests_session + # [T121] requests est synchrone → to_thread pour ne pas geler l'event loop. resp = await asyncio.to_thread( - requests.post, + ha_requests_session().post, endpoint, headers={ "Authorization": f"Bearer {ha_token}", diff --git a/core/openai_compat_provider.py b/core/openai_compat_provider.py index 2249cc7..3950752 100644 --- a/core/openai_compat_provider.py +++ b/core/openai_compat_provider.py @@ -515,21 +515,32 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): "default_model": "glm-5-turbo", "description": "Zhipu AI (Z.ai) — Modèles GLM-5, excellent en code et FR", }, + "dashscope": { + # Coding Plan Alibaba (clé sk-sp-*, endpoint dédié — PAS le pay-as-you-go). + # Intl par défaut ; surcharge possible via DASHSCOPE_BASE_URL. + "base_url": "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions", + "env_key": "DASHSCOPE_API_KEY", + "default_model": "qwen3-coder-next", + "description": ( + "Alibaba DashScope Coding Plan Lite — forfait requêtes " + "(Qwen/GLM/Kimi/MiniMax). Endpoint coding-intl, clé sk-sp-." + ), + }, "ollama_local": { "base_url": "http://127.0.0.1:11434/v1/chat/completions", "env_key": "OLLAMA_API_KEY", # Pas de clé requise pour l'instance locale "default_model": "qwen2.5-coder:7b", - "description": "Ollama Local PC — Inférence locale ultra-rapide sur GPU NVIDIA local", + "description": "Ollama Local PC — Inférence locale ultra-rapide sur RTX 5070 Ti", }, "ollama_pc": { - # Même IP LAN que LMStudioProvider (192.168.1.x, carte Ethernet LAN) — contrairement + # Même IP LAN que LMStudioProvider (192.168.0.84, carte "Ethernet 4") — contrairement # à ollama_local (127.0.0.1), joignable depuis le Deck en prod. Prérequis côté PC : - # Ollama démarré avec OLLAMA_HOST=0.0.0.0 (écoute LAN) + pare-feu Windows ouvert + # Ollama démarré avec OLLAMA_HOST=0.0.0.0 (ou au moins .84) + pare-feu Windows ouvert # sur 11434 pour le LAN, sinon connect timeout (repli cloud silencieux, pas d'erreur bruyante). - "base_url": "http://192.168.1.x:11434/v1/chat/completions", + "base_url": "http://192.168.0.84:11434/v1/chat/completions", "env_key": "OLLAMA_API_KEY", # Pas de clé requise pour l'instance locale "default_model": "domotique-qwen7b:q4", - "description": "Ollama PC via LAN — joignable depuis le Deck (GPU NVIDIA local, fine-tune domotique)", + "description": "Ollama PC via LAN — joignable depuis le Deck (RTX 5070 Ti, fine-tune domotique)", }, } diff --git a/core/runtime_db.py b/core/runtime_db.py index 689a404..552b48e 100644 --- a/core/runtime_db.py +++ b/core/runtime_db.py @@ -473,6 +473,43 @@ def _init_schema(conn: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_vocal_jobs_conv ON vocal_jobs(conversation_id, created_at DESC)" ) + # ─── Comparatifs de modèles (IHM « Benchmarks ») ─── + # Un run = un même prompt envoyé à N modèles ; un result = la mesure réelle + # pour un modèle donné (latence chronométrée côté serveur, jamais estimée). + conn.execute(""" + CREATE TABLE IF NOT EXISTS benchmark_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT UNIQUE NOT NULL, + prompt TEXT NOT NULL, + system_prompt TEXT, + created_at REAL NOT NULL, + finished_at REAL, + status TEXT NOT NULL DEFAULT 'running' + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS benchmark_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + model TEXT NOT NULL, + status TEXT NOT NULL, + latency_ms REAL, + response_text TEXT, + response_chars INTEGER, + prompt_tokens INTEGER, + completion_tokens INTEGER, + cost_usd REAL, + error_message TEXT, + created_at REAL NOT NULL + ) + """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_benchmark_results_run ON benchmark_results(run_id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_benchmark_runs_created ON benchmark_runs(created_at DESC)" + ) + conn.commit() diff --git a/core/tab5_pusher.py b/core/tab5_pusher.py index 9f9cff8..496be52 100644 --- a/core/tab5_pusher.py +++ b/core/tab5_pusher.py @@ -21,17 +21,17 @@ Date : 2026-06-06 """ -import asyncio import logging import os -from typing import Dict, List, Optional import aiohttp +from core.ha_tls import ha_ssl_context # [P0-1.5] politique TLS HA centralisée + logger = logging.getLogger(__name__) # ── Configuration ── -DEFAULT_HA_URL = "http://${HA_HOST:-192.168.1.x}:8123" +DEFAULT_HA_URL = "http://192.168.0.16:8123" DEFAULT_TIMEOUT = 3 # secondes # ── Entités HA cibles ── @@ -51,17 +51,17 @@ class Tab5Pusher: def __init__( self, - ha_url: Optional[str] = None, - ha_token: Optional[str] = None, + ha_url: str | None = None, + ha_token: str | None = None, ): """ Args: - ha_url: URL HA (défaut : http://${HA_HOST:-192.168.1.x}:8123) + ha_url: URL HA (défaut : http://192.168.0.16:8123) ha_token: Token Bearer HA (défaut : env HA_TOKEN) """ self.ha_url = ha_url or os.environ.get("HA_URL", DEFAULT_HA_URL) self.ha_token = ha_token or os.environ.get("HA_TOKEN", "") - self._session: Optional[aiohttp.ClientSession] = None + self._session: aiohttp.ClientSession | None = None if not self.ha_token: logger.warning("[TAB5 PUSHER] HA_TOKEN manquant — les push échoueront silencieusement") @@ -70,7 +70,7 @@ def __init__( # Internals # ────────────────────────────────────────────────────────────── - def _headers(self) -> Dict[str, str]: + def _headers(self) -> dict[str, str]: """Headers Bearer pour l'API HA.""" return { "Authorization": f"Bearer {self.ha_token}", @@ -81,7 +81,10 @@ async def _get_session(self) -> aiohttp.ClientSession: """Retourne (ou crée) la session aiohttp partagée.""" if self._session is None or self._session.closed: timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT) - self._session = aiohttp.ClientSession(timeout=timeout) + # Sans ce connector, la session ignore la politique TLS HA et échoue + # sur un certificat qui ne couvre pas l'IP de HASS_URL. + connector = aiohttp.TCPConnector(ssl=ha_ssl_context()) + self._session = aiohttp.ClientSession(timeout=timeout, connector=connector) return self._session async def _post_state(self, entity_id: str, state: str) -> bool: @@ -108,7 +111,7 @@ async def _post_state(self, entity_id: str, state: str) -> bool: f"[TAB5 PUSHER] ❌ {entity_id} HTTP {resp.status} : {body[:100]}" ) return False - except asyncio.TimeoutError: + except TimeoutError: logger.warning(f"[TAB5 PUSHER] ⏱️ Timeout 3s pour {entity_id}") return False except aiohttp.ClientError as e: @@ -170,7 +173,7 @@ async def push_dreamer_report(self, report: str) -> bool: logger.info(f"[TAB5 PUSHER] Push Dreamer report : {truncated[:60]}...") return await self._post_state(ENTITY_DREAMER, truncated) - async def push_anomaly_report(self, anomalies: List[Dict]) -> bool: + async def push_anomaly_report(self, anomalies: list[dict]) -> bool: """ Formate et pousse un rapport d'anomalies domotiques. @@ -214,7 +217,7 @@ async def close(self) -> None: # Singleton # ────────────────────────────────────────────────────────────────── -_tab5_instance: Optional[Tab5Pusher] = None +_tab5_instance: Tab5Pusher | None = None def get_tab5_pusher() -> Tab5Pusher: diff --git a/core/vocal_tools.py b/core/vocal_tools.py index 55666f0..99045bf 100644 --- a/core/vocal_tools.py +++ b/core/vocal_tools.py @@ -41,7 +41,8 @@ "Pour une température/état : appelle ha_list puis ha_get_state. " "INTERDIT d'inventer une valeur, une entité ou un service. " "Si l'outil échoue ou renvoie unavailable, dis-le clairement. " - "Réponse finale : 1 phrase TTS, français, sans markdown, sans JSON." + "Réponse finale obligatoire : phrases françaises pour Axel (TTS). " + "INTERDIT de lire à voix haute du JSON, du code, ou le nom des outils." ) # Lecture seule — les commandes passent par Zero-LLM (vocal_host). @@ -104,21 +105,21 @@ def _ha_credentials() -> tuple[str, str]: - ha_url = os.environ.get("HA_URL") or os.environ.get("HASS_URL") or "http://${HA_HOST:-192.168.1.x}:8123" + ha_url = os.environ.get("HA_URL") or os.environ.get("HASS_URL") or "http://192.168.0.16:8123" ha_token = os.environ.get("HA_TOKEN") or os.environ.get("HASS_TOKEN") or "" return ha_url.rstrip("/"), ha_token -def _ha_requests_verify() -> bool | str: - """Aligné sur core.ha_tls : False si HA_VERIFY_TLS=false, sinon CA bundle ou True.""" - from core.ha_tls import ha_tls_verification_enabled +def _ha_session(): + """Session HTTP appliquant la politique TLS HA (cf. core.ha_tls). - if not ha_tls_verification_enabled(): - return False - ca_bundle = os.environ.get("HA_CA_BUNDLE", "").strip() - if ca_bundle and os.path.exists(ca_bundle): - return ca_bundle - return True + Passe par une session plutôt que `verify=` seul : c'est le seul moyen de + porter un nom d'hôte imposé (HA_TLS_SERVER_HOSTNAME), nécessaire quand + HASS_URL vise une IP que le certificat ne couvre pas. + """ + from core.ha_tls import ha_requests_session + + return ha_requests_session() def _ha_headers(token: str) -> dict[str, str]: @@ -132,11 +133,10 @@ def _tool_ha_list(query: str, domain: str = "") -> str: if not ha_token: return "Erreur: HASS_TOKEN absent." try: - resp = requests.get( + resp = _ha_session().get( f"{ha_url}/api/states", headers=_ha_headers(ha_token), timeout=10, - verify=_ha_requests_verify(), ) except requests.RequestException as exc: return f"Erreur HA réseau: {exc}" @@ -149,11 +149,20 @@ def _tool_ha_list(query: str, domain: str = "") -> str: entities = [e for e in entities if str(e.get("entity_id", "")).startswith(prefix)] q = (query or "").strip().lower() - matched = [ + candidates = [ e for e in entities if q in str(e.get("entity_id", "")).lower() or q in str(e.get("attributes", {}).get("friendly_name", "")).lower() - ][:20] + ] + # Prioriser les entités vivantes : un doublon mort (ex. capteur Sonoff cloud HS) + # ne doit pas masquer le capteur Zigbee actif quand les deux matchent la requête + # (« température salon » → le Sonoff unavailable cachait le thermomètre Zigbee vivant). + _DEAD_STATES = {"unavailable", "unknown", "none", ""} + alive = [ + e for e in candidates + if str(e.get("state", "")).strip().lower() not in _DEAD_STATES + ] + matched = (alive or candidates)[:20] if not matched: return f"Aucune entité pour '{query}'." @@ -179,11 +188,10 @@ def _tool_ha_get_state(entity_id: str) -> str: if not ha_token: return "Erreur: HASS_TOKEN absent." try: - resp = requests.get( + resp = _ha_session().get( f"{ha_url}/api/states/{entity_id}", headers=_ha_headers(ha_token), timeout=10, - verify=_ha_requests_verify(), ) except requests.RequestException as exc: return f"Erreur HA réseau: {exc}" @@ -255,12 +263,11 @@ def _tool_ha_call_service( return "Erreur: HASS_TOKEN absent." try: - resp = requests.post( + resp = _ha_session().post( f"{ha_url}/api/services/{domain}/{svc_name}", headers=_ha_headers(ha_token), json=svc_data, timeout=10, - verify=_ha_requests_verify(), ) except requests.RequestException as exc: return f"Erreur HA réseau: {exc}" diff --git a/gui_server.py b/gui_server.py index 20f854a..95104e5 100644 --- a/gui_server.py +++ b/gui_server.py @@ -109,7 +109,7 @@ async def lifespan(app: FastAPI): # ── Initialisation du HAFuzzyMatcher (matching entités HA sans LLM) ── try: from core.ha_fuzzy_matcher import init_fuzzy_matcher - _ha_url = os.environ.get("HASS_URL", "https://${HA_HOST:-192.168.1.x}:8123") + _ha_url = os.environ.get("HASS_URL", "https://192.168.0.16:8123") _ha_token = os.environ.get("HASS_TOKEN", "") if not _ha_token: _env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") @@ -194,7 +194,7 @@ async def lifespan(app: FastAPI): try: from core.watchdog import create_watchdog_daemon _watchdog = create_watchdog_daemon({ - "mqtt_host": os.getenv("MQTT_HOST", "${HA_HOST:-192.168.1.x}"), + "mqtt_host": os.getenv("MQTT_HOST", "192.168.0.16"), "mqtt_port": int(os.getenv("MQTT_PORT", "1883")), "mqtt_username": os.getenv("MQTT_USERNAME"), "mqtt_password": os.getenv("MQTT_PASSWORD"), @@ -268,7 +268,7 @@ async def lifespan(app: FastAPI): # Configuration CORS pilotée par l'environnement. # MOTEUR_CORS_ORIGINS : liste d'origines séparées par des virgules -# (ex. "http://192.168.1.x:8000,http://localhost:8000"). +# (ex. "http://192.168.0.43:8000,http://localhost:8000"). # Sécurité : la combinaison allow_origins=["*"] + allow_credentials=True est # invalide/dangereuse (CSRF cross-origin authentifié). Si aucune origine n'est # définie, on retombe sur "*" SANS credentials. @@ -425,6 +425,17 @@ async def lifespan(app: FastAPI): app.include_router(observability_router, dependencies=_AUTH_DEP) +# [VOCAL] Observation du pipeline vocal — stats, conversations, jobs, config. +# Complète /api/vocal/audit et /api/vocal/abort, définies dans api/routes/agents.py. +from api.routes.vocal import router as vocal_router + +app.include_router(vocal_router, dependencies=_AUTH_DEP) + +# [INSTALLATION] Diagnostic effectif de l'installation (clés, bases, providers, HA). +from api.routes.setup import router as setup_router + +app.include_router(setup_router, dependencies=_AUTH_DEP) + CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") @@ -1000,31 +1011,49 @@ async def dispatch(self, request: Request, call_next): @app.get("/version", tags=["Configuration"]) async def get_version(): - """Retourne la version du moteur, le hash git et la date de build.""" + """Retourne la version du moteur, le hash git et la date de build. + + Priorité du stamp : `.deploy_meta.json` (écrit par overlay Deck) puis `git`. + Évite le faux hash d'un `.git` parasite / snapshot obsolète sur le Deck. + """ + import json as _json import sys as _sys from core import __version__ git_hash = "unknown" build_date = "unknown" + _root = os.path.dirname(os.path.abspath(__file__)) + _meta_path = os.path.join(_root, ".deploy_meta.json") try: - proc = await asyncio.create_subprocess_exec( - "git", "rev-parse", "--short", "HEAD", - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, - cwd=os.path.dirname(os.path.abspath(__file__)), - ) - out, _ = await proc.communicate() - if proc.returncode == 0: - git_hash = out.decode().strip() - proc2 = await asyncio.create_subprocess_exec( - "git", "show", "-s", "--format=%cI", "HEAD", - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, - cwd=os.path.dirname(os.path.abspath(__file__)), - ) - out2, _ = await proc2.communicate() - if proc2.returncode == 0: - build_date = out2.decode().strip() + if os.path.isfile(_meta_path): + with open(_meta_path, encoding="utf-8") as _fh: + _meta = _json.load(_fh) + git_hash = str(_meta.get("git_hash") or git_hash) + build_date = str(_meta.get("build_date") or build_date) except Exception: pass + if git_hash == "unknown" or build_date == "unknown": + try: + if git_hash == "unknown": + proc = await asyncio.create_subprocess_exec( + "git", "rev-parse", "--short", "HEAD", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + cwd=_root, + ) + out, _ = await proc.communicate() + if proc.returncode == 0: + git_hash = out.decode().strip() + if build_date == "unknown": + proc2 = await asyncio.create_subprocess_exec( + "git", "show", "-s", "--format=%cI", "HEAD", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + cwd=_root, + ) + out2, _ = await proc2.communicate() + if proc2.returncode == 0: + build_date = out2.decode().strip() + except Exception: + pass return { "version": __version__, "git_hash": git_hash, diff --git a/ihm-v2/README.md b/ihm-v2/README.md new file mode 100644 index 0000000..c3232af --- /dev/null +++ b/ihm-v2/README.md @@ -0,0 +1,88 @@ +# IHM v2 — tab5-engine + +Interface de pilotage, de réglage et de compréhension du moteur multi-agents. + +**Stack** : React 19 + Vite 8 + TypeScript + Zustand (état) + React Query (data serveur) ++ Tailwind CSS 4 + Chart.js + React Flow. + +## Principe directeur : ne jamais afficher de donnée inventée + +C'est la règle qui prime sur toutes les autres. Concrètement : + +- **Aucun repli sur des mocks.** Si un endpoint échoue, l'erreur remonte à la vue et + s'affiche avec son message. Les modules API ne renvoient jamais de données de + substitution — trois vues le faisaient et présentaient des chiffres crédibles mais + entièrement fictifs. +- **« — » signifie « jamais mesuré »**, pas zéro (composant `Value`). +- **Chaque bloc affiche sa source** (prop `source` de `Card`) : l'endpoint moteur d'où + viennent les chiffres, pour pouvoir toujours remonter à l'origine d'une valeur. +- **Une requête suspendue n'est pas un résultat vide** (`queryPhase` + `PausedState`) : + le motif `isLoading / isError / vide` laissait afficher « aucune donnée » alors que + rien n'était revenu du serveur. +- **Un contrôle qui n'a pas de backend n'est pas affiché.** Pas d'interrupteur + décoratif, pas de bouton qui échoue en silence. + +## Navigation + +| Groupe | Vues | +| --- | --- | +| **Pilotage** | Vue d'ensemble (SSE live), Chat (sélecteur de tier), Lancer une tâche | +| **Domotique** | Domotique & Vocal — santé HA réelle, entités, pipeline vocal, journal STT | +| **Moteur** | Agents, Workflows, LLMs & routage, Prompt Studio, Comparatif modèles | +| **Autonomie** | Agents autonomes — Daemon, Dreamer/DreamCoder, Auditeur | +| **Optimisation** | Opérations, Observabilité, Comptes API, Configuration (~45 réglages) | +| **Comprendre** | Installation (diagnostic effectif), Documentation (+ guides d'optimisation) | + +## Authentification (propre, sans clé dans l'URL) + +- **REST** (`/api/*`) : header `Authorization: Bearer `. +- **SSE** (`/api/stream`) : **ticket éphémère** à usage unique via `POST /api/auth/ticket`, + puis `?ticket=`. La clé ne transite **jamais** dans une URL. + +La clé est stockée en `localStorage` (outil personnel sur LAN). + +## Développement + +```bash +cd ihm-v2 && npm install && npm run dev +``` + +Vite sur , proxy `/api`, `/v1`, `/ws`, `/version` → `:8000`. +Cible surchargeable : `MOTEUR_API_URL=http://192.168.0.43:8000 npm run dev`. + +Le moteur (`gui_server.py`) doit tourner et autoriser l'origine de dev dans +`MOTEUR_CORS_ORIGINS` (ex : `http://localhost:5173`). + +## Build & déploiement + +```bash +npm run build # → dist/, base "/" +``` + +`gui_server.py` sert `ihm-v2/dist` **à la racine `/`** si le build existe, et bascule +sur l'ancienne IHM (`static/`) sinon. L'IHM v1 reste accessible sous `/v1`. + +> Le script `build:v2` (base `/v2/`) est un reliquat de la migration : le moteur ne +> monte plus rien sous `/v2`. Utiliser `npm run build`. + +## Structure + +``` +src/ +├── api/ # un module par domaine ; contrats vérifiés contre le backend réel +├── components/ +│ ├── ui/ # primitives partagées (Card, Value, Explain, queryPhase…) +│ ├── config/ # contrôles d'édition de config.json (toggle, list ordonnée, select) +│ ├── common/ # ApiKeyGate, ErrorBoundary, Markdown, historique de sessions +│ └── layout/ # coquille + navigation groupée +├── hooks/ # useEngineStream (SSE avec reconnexion par ticket) +├── state/ # stores Zustand (moteur, chat, navigation) +├── types/ # contrat moteur↔IHM + registre des réglages (config.ts) +└── views/ # une vue par entrée de navigation +``` + +### Ajouter un réglage + +Ajouter une entrée dans `CONFIG_SECTIONS` (`src/types/config.ts`) : chemin pointé dans +`config.json`, type de contrôle, aide, et `impact` (l'effet réel sur le comportement du +moteur). La vue Configuration s'occupe du reste (dirty-tracking, merge partiel, recherche). diff --git a/ihm-v2/src/App.tsx b/ihm-v2/src/App.tsx new file mode 100644 index 0000000..b1f22a4 --- /dev/null +++ b/ihm-v2/src/App.tsx @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useState } from "react"; +import { getApiKey, registerAuthErrorHandler } from "./api/client"; +import { useEngineStream } from "./hooks/useEngineStream"; +import AppLayout from "./components/layout/AppLayout"; +import { ApiKeyGate } from "./components/common/ApiKeyGate"; +import { ErrorBoundary } from "./components/common/ErrorBoundary"; + +// Pilotage +import { Dashboard } from "./views/Dashboard"; +import { Chat } from "./views/Chat"; +import TaskRunner from "./views/TaskRunner"; + +// Domotique & vocal +import HomeAssistantView from "./views/HomeAssistant"; + +// Moteur & codage +import { AgentsManager } from "./views/AgentsManager"; +import WorkflowBuilder from "./views/WorkflowBuilder"; +import { LLMRegistry } from "./views/LLMRegistry"; +import { PromptStudio } from "./views/PromptStudio"; +import Benchmarks from "./views/Benchmarks"; + +// Autonomie +import Autonomy from "./views/Autonomy"; + +// Optimisation +import { Operations } from "./views/Operations"; +import { Observability } from "./views/Observability"; +import { APIAccounts } from "./views/APIAccounts"; +import { Configuration } from "./views/Configuration"; + +// Comprendre +import Setup from "./views/Setup"; +import Docs from "./views/Docs"; + +import { useUiStore } from "./state/uiStore"; + +export default function App() { + const [needsKey, setNeedsKey] = useState(!getApiKey()); + const [authNonce, setAuthNonce] = useState(0); + const view = useUiStore((s) => s.view); + + const handleAuthError = useCallback(() => setNeedsKey(true), []); + useEffect(() => { + registerAuthErrorHandler(handleAuthError); + return () => registerAuthErrorHandler(null); + }, [handleAuthError]); + useEngineStream({ enabled: !needsKey }); + + const renderView = () => { + switch (view) { + // Pilotage + case "chat": return ; + case "taskrunner": return ; + // Domotique & vocal + case "homeassistant": return ; + // Moteur & codage + case "agents": return ; + case "workflow": return ; + case "llm": return ; + case "prompt": return ; + case "benchmarks": return ; + // Autonomie + case "autonomy": return ; + // Optimisation + case "operations": return ; + case "observability": return ; + case "accounts": return ; + case "config": return ; + // Comprendre + case "setup": return ; + case "docs": return ; + default: return ; + } + }; + + return ( + <> + { + setNeedsKey(false); + setAuthNonce((n) => n + 1); + }} + /> + +
+ {renderView()} +
+
+ + ); +} diff --git a/ihm-v2/src/api/accounts.ts b/ihm-v2/src/api/accounts.ts new file mode 100644 index 0000000..a10c657 --- /dev/null +++ b/ihm-v2/src/api/accounts.ts @@ -0,0 +1,64 @@ +import { apiFetch } from "./client"; + +export interface ProviderAccount { + name: string; + balance_usd: number | null; + balance_eur: number | null; + quota_rpm: number | null; + quota_rpd: number | null; + used_rpm: number | null; + used_rpd: number | null; + used_pct: number; + last_sync: string | null; + dashboard_url: string; + status: string; + plan: string; + /** Identifiant technique stable pour POST /api/billing/manual (absent sur les providers suivis par quota). */ + provider_key?: string; +} + +export interface BillingAccountsResponse { + providers: ProviderAccount[]; + /** Dépense réelle depuis le 1er du mois calendaire courant (pas un solde de compte). */ + total_cost_usd_month: number; +} + +/** + * `GET /api/billing` existe depuis #T178 (agrégateur de soldes réels). Le + * try/catch qui renvoyait une réponse vide datait d'avant : il masquait + * désormais les vraies pannes derrière une grille silencieusement vide. + */ +export async function fetchAccounts(): Promise { + return apiFetch("/api/billing"); +} + +/** + * `GET /api/metrics/cost-per-success` (core/elo_scorer.py::get_cost_per_successful_task) + * renvoie un dict `{providers: {: {total_cost_usd, successful_tasks, cost_per_success_usd}}}`, + * pas un tableau `by_provider` — normalisé ici pour le graphique. + */ +export async function fetchCostPerSuccess(): Promise<{ by_provider: Array<{ provider: string; cost_usd: number; tasks: number }> }> { + const res = await apiFetch<{ + providers: Record; + }>("/api/metrics/cost-per-success"); + const by_provider = Object.entries(res.providers ?? {}).map(([provider, stats]) => ({ + provider, + cost_usd: stats.total_cost_usd, + tasks: stats.successful_tasks, + })); + return { by_provider }; +} + +// `reauthorizeGoogle` a été retirée : elle appelait POST /api/auth/google/reauthorize, +// route qui n'existe nulle part côté backend (vérifié par grep sur api/, core/ et +// gui_server.py). Le bouton correspondant échouait donc toujours en 404, et son +// `onSuccess` — qui affichait « Redirection vers le flow OAuth » — ne s'exécutait +// jamais. La réautorisation passe par `python setup_google_oauth.py`. + +/** Enregistre un solde saisi à la main pour un provider sans API de solde (cf. status "non_suivi"). */ +export async function setManualBalance(provider: string, balance_usd: number, note = ""): Promise { + return apiFetch("/api/billing/manual", { + method: "POST", + body: JSON.stringify({ provider, balance_usd, note }), + }); +} \ No newline at end of file diff --git a/ihm-v2/src/api/autonomy.ts b/ihm-v2/src/api/autonomy.ts new file mode 100644 index 0000000..cd00d61 --- /dev/null +++ b/ihm-v2/src/api/autonomy.ts @@ -0,0 +1,81 @@ +import { apiFetch } from "./client"; + +/** + * Client des agents autonomes (Daemon Sentinelle, Dreamer/DreamCoder, Auditeur). + * + * Contrats relevés en direct sur le moteur — les champs optionnels le sont parce + * que le backend ne les renseigne pas toujours (ex: `last_error` reste null tant + * qu'aucun cycle n'a échoué), pas par prudence défensive. + */ + +export interface DaemonAnomaly { + check: string; + status: "ok" | "info" | "warning" | "error" | string; + alert: string; +} + +export interface DaemonStatus { + running: boolean; + enabled: boolean; + last_cycle_at: string | null; + last_cycle_duration_ms: number | null; + total_cycles: number; + anomalies: DaemonAnomaly[]; + errors_count: number; + last_error: string | null; + interval_minutes: number; + started_at: string | null; +} + +export async function fetchDaemonStatus(): Promise { + return apiFetch("/api/daemon/status"); +} + +export interface DaemonLogEntry { + cycle_id: string; + timestamp: string; + duration_ms: number; + anomalies_count: number; + checks_summary: Record; +} + +export async function fetchDaemonLogs(limit = 30): Promise<{ logs: DaemonLogEntry[] }> { + return apiFetch(`/api/daemon/logs?limit=${limit}`); +} + +export interface DreamerStatus { + running: boolean; + enabled: boolean; + last_run_at: string | null; + last_run_duration_ms: number | null; + total_runs: number; + last_report: { + path?: string; + summary?: string; + actions?: Record>; + } | null; + idle_trigger_hours?: number; +} + +export async function fetchDreamerStatus(): Promise { + return apiFetch("/api/dreamer/status"); +} + +/** Déclenche un cycle de consolidation immédiat. Consomme des appels LLM. */ +export async function triggerDreamer(): Promise<{ status: string; report: unknown }> { + return apiFetch("/api/dreamer/trigger", { method: "POST" }); +} + +/** Configuration des agents persistants (sous-arbre persistent_agents de config.json). */ +export async function fetchPersistentConfig(): Promise> { + return apiFetch>("/api/persistent-agents/config"); +} + +export async function updatePersistentConfig( + patch: Record, +): Promise<{ message: string; config: Record }> { + return apiFetch("/api/persistent-agents/config", { + method: "POST", + body: JSON.stringify(patch), + }); +} diff --git a/ihm-v2/src/api/backlog.ts b/ihm-v2/src/api/backlog.ts new file mode 100644 index 0000000..67938c7 --- /dev/null +++ b/ihm-v2/src/api/backlog.ts @@ -0,0 +1,82 @@ +import { apiFetch } from "./client"; + +/** + * Client du backlog de tâches — la VRAIE file d'attente du moteur. + * + * L'ancienne vue « Lancer » interrogeait `/api/queue`, qui n'existe nulle part + * côté backend, et repliait sur deux tâches inventées. La file réelle est + * `core/backlog_db` exposée par `api/routes/backlog.py` (préfixe `/api/backlog`), + * et c'est elle que consomme le DreamCoder pour son travail de fond. + */ + +export type BacklogStatus = + | "pending" + | "running" + | "completed" + | "failed" + | "paused" + | "abandoned"; + +export interface BacklogTask { + id: number; + title: string; + description: string; + /** 1 = haute, 2 = moyenne, 3 = basse. */ + priority: number; + status: BacklogStatus; + created_at: number; + scheduled_at: number | null; + git_branch: string | null; + result_summary: string | null; + tokens_used: number | null; + error_message: string | null; + retries: number; +} + +export type BacklogStats = Record; + +export async function fetchBacklogTasks(): Promise { + return apiFetch("/api/backlog/tasks"); +} + +export async function fetchBacklogStats(): Promise { + return apiFetch("/api/backlog/stats"); +} + +export async function createBacklogTask(input: { + title: string; + description: string; + priority?: number; + scheduled_at?: number | null; +}): Promise<{ status: string; task_id: number }> { + return apiFetch("/api/backlog/tasks", { + method: "POST", + body: JSON.stringify({ + title: input.title, + description: input.description, + priority: input.priority ?? 2, + scheduled_at: input.scheduled_at ?? null, + }), + }); +} + +/** + * Change le statut d'une tâche. + * + * ⚠ Côté backend, `approved` et `rejected` déclenchent de VRAIES opérations Git + * (merge --no-ff puis suppression de branche, ou branch -D). Ce ne sont pas de + * simples changements de libellé — l'IHM doit demander confirmation. + */ +export async function updateBacklogTask( + id: number, + status: "approved" | "rejected" | "pending" | BacklogStatus, +): Promise<{ status: string }> { + return apiFetch(`/api/backlog/tasks/${id}`, { + method: "PUT", + body: JSON.stringify({ status }), + }); +} + +export async function deleteBacklogTask(id: number): Promise<{ status: string }> { + return apiFetch(`/api/backlog/tasks/${id}`, { method: "DELETE" }); +} diff --git a/ihm-v2/src/api/benchmarks.ts b/ihm-v2/src/api/benchmarks.ts new file mode 100644 index 0000000..4df773e --- /dev/null +++ b/ihm-v2/src/api/benchmarks.ts @@ -0,0 +1,110 @@ +import { apiFetch } from './client'; + +/** + * Client de la vue « Comparatif modèles ». + * + * Deux natures de données, volontairement séparées à l'écran : + * - `observed` : ce que les modèles ont RÉELLEMENT fait en production, croisé + * depuis token_usage / model_elo_scores / routing_decisions. Un champ jamais + * mesuré vaut `null` — la vue affiche « — », jamais un zéro trompeur. + * - `runs` : comparatifs déclenchés explicitement, dont la latence est + * chronométrée côté serveur autour de l'appel provider. + * + * Aucun repli sur des données simulées : si un endpoint échoue, l'erreur remonte + * à la vue, qui l'affiche. (Avant, ce module renvoyait des mocks en silence — + * la page entière était une fiction crédible.) + */ + +/** Performance réellement observée en production pour un modèle. */ +export interface ObservedModelPerf { + model: string; + calls: number; + total_tokens: number; + cost_usd: number; + avg_cost_per_call_usd: number | null; + elo: number | null; + matches: number | null; + win_rate: number | null; + avg_latency_ms: number | null; + success_rate: number | null; +} + +/** Résultat mesuré d'un modèle dans un comparatif déclenché manuellement. */ +export interface BenchmarkResult { + model: string; + status: 'success' | 'error'; + latency_ms: number | null; + response_text: string | null; + response_chars: number; + prompt_tokens: number | null; + completion_tokens: number | null; + cost_usd: number | null; + error_message: string | null; +} + +export interface BenchmarkRun { + run_id: string; + prompt: string; + system_prompt: string | null; + created_at: number; + finished_at: number | null; + status: string; + results: BenchmarkResult[]; +} + +export interface BenchmarksPayload { + period: string; + generated_at: string; + observed: ObservedModelPerf[]; + runs: BenchmarkRun[]; +} + +/** Comparatif complet : performance observée + historique des runs manuels. */ +export async function fetchBenchmarks(period = '30d', limit = 10): Promise { + return apiFetch( + `/api/metrics/benchmarks?period=${encodeURIComponent(period)}&limit=${limit}`, + ); +} + +/** + * Lance un comparatif réel : le même prompt part vers chaque modèle en parallèle. + * ⚠ Déclenche de vrais appels LLM facturés (le backend plafonne à 8 modèles). + */ +export async function runBenchmark( + prompt: string, + models: string[], + options: { systemPrompt?: string; maxTokens?: number; temperature?: number } = {}, +): Promise { + return apiFetch('/api/metrics/benchmarks', { + method: 'POST', + body: JSON.stringify({ + prompt, + models, + system_prompt: options.systemPrompt ?? null, + max_tokens: options.maxTokens ?? 512, + temperature: options.temperature ?? 0.3, + }), + }); +} + +/** Classement Elo par domaine (GET /api/metrics/elo — scores réels, pas d'historique). */ +export interface EloEntry { + elo: number; + matches: number; + wins: number; + losses: number; + win_rate: number; + avg_latency_ms: number | null; + last_updated: number; +} + +export interface EloPayload { + /** modèle/agent → domaine → score */ + scores: Record>; + domains: string[]; + leaderboards: Record>>; +} + +export async function fetchElo(): Promise { + return apiFetch('/api/metrics/elo'); +} diff --git a/ihm-v2/src/api/ha.ts b/ihm-v2/src/api/ha.ts new file mode 100644 index 0000000..0a1758e --- /dev/null +++ b/ihm-v2/src/api/ha.ts @@ -0,0 +1,118 @@ +import { apiFetch } from "./client"; + +/** + * Client Home Assistant + assistant vocal. + * + * Point important : `fetchHaHealth` ne renvoie jamais un « connecté » supposé. + * Le backend effectue un vrai appel à Home Assistant et rapporte la cause exacte + * en cas d'échec (TLS, token refusé, hôte injoignable). L'ancienne vue affichait + * un pastille verte « ● Connecté » codée en dur, jamais vérifiée. + */ + +export interface HaHealth { + url: string; + token_configured: boolean; + verify_tls: boolean; + ca_bundle: string | null; + reachable: boolean; + latency_ms: number | null; + version: string | null; + location_name?: string | null; + entity_count: number | null; + domains: Record; + error: string | null; +} + +export async function fetchHaHealth(): Promise { + return apiFetch("/api/ha/health"); +} + +export interface HaEntity { + entity_id: string; + friendly_name: string; + state: string | null; + unit: string | null; + device_class: string | null; + last_changed: string | null; +} + +export async function fetchHaEntities(params: { + search?: string; + domain?: string; + limit?: number; +}): Promise<{ total_matched: number; returned: number; entities: HaEntity[] }> { + const qs = new URLSearchParams(); + if (params.search) qs.set("search", params.search); + if (params.domain) qs.set("domain", params.domain); + qs.set("limit", String(params.limit ?? 200)); + return apiFetch(`/api/ha/entities?${qs.toString()}`); +} + +/** Appelle un service HA sur une entité. Action réelle sur le domicile. */ +export async function controlHaEntity(input: { + entity_id: string; + service: string; + domain?: string; + service_data?: Record; +}): Promise { + return apiFetch("/api/ha/control", { method: "POST", body: JSON.stringify(input) }); +} + +/* ── Assistant vocal ──────────────────────────────────────────────────────── */ + +export interface VocalStats { + period: string; + total_events: number; + total_exchanges: number; + tts_enabled_events: number; + latency_ms: { avg: number | null; min: number | null; max: number | null }; + by_mode: Array<{ source_mode: string | null; n: number; avg_ms: number | null }>; + by_device: Array<{ device_id: string; n: number }>; + by_routing: Array<{ routing_type: string; n: number }>; +} + +export async function fetchVocalStats(period = "7d"): Promise { + return apiFetch(`/api/vocal/stats?period=${encodeURIComponent(period)}`); +} + +export interface VocalAuditEntry { + id: number; + created_at: string; + session_id: string | null; + user_prompt: string | null; + source_type: string | null; + source_mode: string | null; + tts_enabled: number | null; + device_id: string | null; + routing_type: string | null; + agents_used: string | null; + response_text: string | null; + latency_ms: number | null; + phase: string | null; +} + +export async function fetchVocalAudit(limit = 50): Promise<{ count: number; logs: VocalAuditEntry[] }> { + return apiFetch(`/api/vocal/audit?limit=${limit}`); +} + +export interface VocalConfig { + ha_model: string | null; + executor_model: string | null; + semantic_cache: { enabled?: boolean; similarity_threshold?: number }; + secrets_present: Record; + tts_cloud_available: boolean; +} + +export async function fetchVocalConfig(): Promise { + return apiFetch("/api/vocal/config"); +} + +/** Interrompt les flux vocaux en cours (barge-in Tab5). */ +export async function abortVocal(): Promise<{ aborted: number; active_before: unknown }> { + return apiFetch("/api/vocal/abort", { method: "POST", body: JSON.stringify({}) }); +} + +/** Voix TTS cloud disponibles. */ +export async function fetchTtsVoices(): Promise { + return apiFetch("/api/tts-cloud/voices"); +} diff --git a/ihm-v2/src/api/setup.ts b/ihm-v2/src/api/setup.ts new file mode 100644 index 0000000..34a490e --- /dev/null +++ b/ihm-v2/src/api/setup.ts @@ -0,0 +1,39 @@ +import { apiFetch } from "./client"; + +/** + * Diagnostic d'installation. Chaque contrôle est effectué réellement côté + * serveur (fichier lu, table comptée, connexion tentée) et porte une + * remédiation quand il échoue. + */ + +export type CheckStatus = "ok" | "warn" | "error" | "info"; + +export interface SetupCheck { + name: string; + label: string; + status: CheckStatus; + detail: string; + fix: string | null; +} + +export interface SetupGroup { + id: string; + label: string; + checks: SetupCheck[]; +} + +export interface SetupDiagnostics { + groups: SetupGroup[]; + summary: { + total: number; + ok: number; + warnings: number; + errors: number; + /** Vrai si aucun contrôle bloquant n'est en erreur. */ + operational: boolean; + }; +} + +export async function fetchSetupDiagnostics(): Promise { + return apiFetch("/api/setup/diagnostics"); +} diff --git a/ihm-v2/src/api/workflows.ts b/ihm-v2/src/api/workflows.ts new file mode 100644 index 0000000..1beba3b --- /dev/null +++ b/ihm-v2/src/api/workflows.ts @@ -0,0 +1,139 @@ +import { apiFetch } from './client'; + +export interface WorkflowNodeData { + label: string; + agent: string; + model: string; + tier: string; + [key: string]: unknown; +} + +export interface WorkflowNode { + id: string; + type: string; + position: { x: number; y: number }; + data: WorkflowNodeData; +} + +export interface WorkflowEdge { + id: string; + source: string; + target: string; + label?: string; + animated?: boolean; +} + +export interface WorkflowGraph { + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; +} + +/** Format réel de `agents_workflows.json` (vérifié en direct via `GET /api/workflows`) : + * nœuds à `x`/`y` plats + `agentName`/`label` au niveau racine (pas `position`/`data`), + * arêtes `from`/`to` (pas `source`/`target`), clé `connections` (pas `edges`). Très + * différent du format `WorkflowGraph` attendu par `@xyflow/react` — normalisé ici. + */ +interface RawWorkflowNode { + id: string; + type?: string; + label?: string; + x?: number; + y?: number; + tier?: string | null; + agentName?: string | null; + position?: { x: number; y: number }; + data?: WorkflowNodeData; +} +interface RawWorkflowEdge { + id: string; + from?: string; + to?: string; + fromPort?: string; + source?: string; + target?: string; + label?: string; + animated?: boolean; +} + +function normalizeNode(n: RawWorkflowNode): WorkflowNode { + return { + id: n.id, + type: 'agent', // seul type de nœud custom enregistré dans WorkflowBuilder (nodeTypes) + position: n.position ?? { x: n.x ?? 0, y: n.y ?? 0 }, + data: n.data ?? { + label: n.label ?? n.agentName ?? n.id, + agent: n.agentName ?? '?', + model: 'auto', + tier: n.tier ?? 'moyen', + }, + }; +} + +function normalizeEdge(e: RawWorkflowEdge): WorkflowEdge { + return { + id: e.id, + source: e.source ?? e.from ?? '', + target: e.target ?? e.to ?? '', + label: e.label ?? e.fromPort, + }; +} + +/** + * Récupère le graphe de workflow actuel, normalisé depuis le format brut du backend. + * + * Aucun repli sur des données factices : `GET /api/workflows` existe bel et bien + * (`api/routes/workflows.py`, `@router.get("")` sous le préfixe `/api/workflows`) + * et renvoie `{nodes: [], connections: [], metadata: {}}` quand rien n'est encore + * sauvegardé. Une erreur ici est une vraie erreur : elle doit remonter à la vue. + */ +export async function fetchWorkflow(): Promise { + const raw = await apiFetch<{ + nodes?: RawWorkflowNode[]; + edges?: RawWorkflowEdge[]; + connections?: RawWorkflowEdge[]; + }>('/api/workflows'); + return { + nodes: (raw.nodes ?? []).map(normalizeNode), + edges: (raw.edges ?? raw.connections ?? []).map(normalizeEdge), + }; +} + +/** + * Sauvegarde le graphe complet dans `agents_workflows.json` (POST — le backend + * n'expose pas de PUT sur cette route). Le backend écrit le format qu'on lui + * envoie ; on conserve donc la clé `connections` attendue par `WorkflowBridge` + * en plus de `edges`, pour rester lisible par le moteur comme par l'IHM. + */ +export async function saveWorkflow(nodes: WorkflowNode[], edges: WorkflowEdge[]): Promise { + await apiFetch('/api/workflows', { + method: 'POST', + body: JSON.stringify({ + nodes: nodes.map((n) => ({ + id: n.id, + type: n.type, + label: n.data.label, + agentName: n.data.agent, + tier: n.data.tier, + x: n.position.x, + y: n.position.y, + })), + connections: edges.map((e) => ({ id: e.id, from: e.source, to: e.target, label: e.label })), + metadata: { saved_by: 'ihm-v2', saved_at: new Date().toISOString() }, + }), + }); +} + +/** Applique le workflow sauvegardé au moteur (recharge le WorkflowBridge). */ +export async function applyWorkflow(): Promise<{ + message: string; + agents: string[]; + custom_agents: string[]; +}> { + return apiFetch('/api/workflows/apply', { method: 'POST' }); +} + +/** Liste les workflows nommés disponibles. */ +export async function listWorkflows(): Promise { + const res = await apiFetch<{ workflows: string[] }>('/api/workflows/list'); + return res.workflows ?? []; +} diff --git a/ihm-v2/src/components/config/ConfigFieldControl.tsx b/ihm-v2/src/components/config/ConfigFieldControl.tsx new file mode 100644 index 0000000..24b7a84 --- /dev/null +++ b/ihm-v2/src/components/config/ConfigFieldControl.tsx @@ -0,0 +1,204 @@ +/** + * Rend le contrôle d'édition adapté au type d'un ConfigField. + * + * Types gérés : toggle, number, text, time, select (énumération fermée) et + * list (liste ordonnée de chaînes — tiers de modèles, scopes, exclusions). + * Le type `list` est essentiel : les listes de modèles par tier définissent + * l'ordre exact de la cascade, et l'ordre compte. + */ +import { useState } from "react"; +import { GripVertical, X, Plus, ArrowUp, ArrowDown } from "lucide-react"; +import type { ConfigField } from "../../types/config"; + +interface Props { + field: ConfigField; + value: unknown; + dirty: boolean; + onChange: (value: unknown) => void; +} + +export function ConfigFieldControl({ field, value, dirty, onChange }: Props) { + const effective = value ?? field.fallback; + + return ( +
+
+ {field.label} + {dirty && } + {field.path} +
+

{field.help}

+ {field.impact && ( +

+ Effet : {field.impact} +

+ )} + + {field.type === "toggle" ? ( + + ) : field.type === "select" ? ( + + ) : field.type === "list" ? ( + + ) : ( + { + const raw = e.target.value; + onChange(field.type === "number" ? (raw === "" ? "" : Number(raw)) : raw); + }} + className="mt-1 w-full max-w-md rounded-lg border border-slate-700 bg-slate-800 px-3 py-1.5 text-slate-100 outline-none focus:border-sky-500" + /> + )} +
+ ); +} + +/** + * Éditeur de liste de chaînes. Quand `ordered` est vrai, l'ordre est signifiant + * (cas des tiers : le moteur essaie les modèles dans cet ordre) et des flèches + * de réordonnancement sont proposées. + */ +function ListEditor({ + items, + ordered, + placeholder, + onChange, +}: { + items: string[]; + ordered?: boolean; + placeholder?: string; + onChange: (v: string[]) => void; +}) { + const [draft, setDraft] = useState(""); + + const move = (from: number, to: number) => { + if (to < 0 || to >= items.length) return; + const next = [...items]; + const [it] = next.splice(from, 1); + next.splice(to, 0, it); + onChange(next); + }; + + return ( +
+ {items.length === 0 ? ( +

Liste vide.

+ ) : ( +
    + {items.map((item, i) => ( +
  1. + {ordered && ( + <> + + + {i + 1} + + + )} + {item} + {ordered && ( + <> + + + + )} + +
  2. + ))} +
+ )} +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && draft.trim()) { + e.preventDefault(); + onChange([...items, draft.trim()]); + setDraft(""); + } + }} + placeholder={placeholder ?? "Ajouter une entrée…"} + className="w-full max-w-xs rounded-lg border border-slate-700 bg-slate-800 px-3 py-1.5 text-xs text-slate-100 outline-none focus:border-sky-500" + /> + +
+
+ ); +} diff --git a/ihm-v2/src/components/layout/AppLayout.tsx b/ihm-v2/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..a8eddca --- /dev/null +++ b/ihm-v2/src/components/layout/AppLayout.tsx @@ -0,0 +1,179 @@ +/** + * Coquille de l'application : navigation groupée + bandeau d'état moteur. + * + * La navigation était une liste plate de 13 entrées sans hiérarchie, où + * « Config moteur » et « Réglages » éditaient tous deux config.json sans qu'on + * sache lequel choisir. Elle est désormais organisée par intention : piloter, + * domotique, construire, autonomie, optimiser, comprendre. + * + * Le bandeau d'état affiche la santé RÉELLE de la connexion au moteur (état du + * flux SSE), là où l'ancien pied de sidebar affichait « Moteur en ligne » en + * dur, quelle que soit la situation. + */ +import { useUIStore } from '../../state/uiStore'; +import type { View } from '../../state/uiStore'; +import { useEngineStore } from '../../state/engineStore'; +import { + Bot, BrainCircuit, Settings2, BarChart3, KeyRound, Rocket, Menu, + LayoutDashboard, MessageSquare, PenLine, Activity, Gauge, Wrench, + HousePlug, Moon, BookOpen, Wallet, +} from 'lucide-react'; + +interface NavItem { + id: View; + label: string; + icon: React.ElementType; +} + +interface NavGroup { + label: string; + items: NavItem[]; +} + +const NAV_GROUPS: NavGroup[] = [ + { + label: 'Pilotage', + items: [ + { id: 'dashboard', label: "Vue d'ensemble", icon: LayoutDashboard }, + { id: 'chat', label: 'Chat', icon: MessageSquare }, + { id: 'taskrunner', label: 'Lancer une tâche', icon: Rocket }, + ], + }, + { + label: 'Domotique', + items: [ + { id: 'homeassistant', label: 'Domotique & Vocal', icon: HousePlug }, + ], + }, + { + label: 'Moteur', + items: [ + { id: 'agents', label: 'Agents', icon: Bot }, + { id: 'workflow', label: 'Workflows', icon: Settings2 }, + { id: 'llm', label: 'LLMs & routage', icon: BrainCircuit }, + { id: 'prompt', label: 'Prompt Studio', icon: PenLine }, + { id: 'benchmarks', label: 'Comparatif modèles', icon: BarChart3 }, + ], + }, + { + label: 'Autonomie', + items: [ + { id: 'autonomy', label: 'Agents autonomes', icon: Moon }, + ], + }, + { + label: 'Optimisation', + items: [ + { id: 'operations', label: 'Opérations', icon: Gauge }, + { id: 'observability', label: 'Observabilité', icon: Activity }, + { id: 'accounts', label: 'Comptes API', icon: Wallet }, + { id: 'config', label: 'Configuration', icon: Wrench }, + ], + }, + { + label: 'Comprendre', + items: [ + { id: 'setup', label: 'Installation', icon: KeyRound }, + { id: 'docs', label: 'Documentation', icon: BookOpen }, + ], + }, +]; + +export default function AppLayout({ children }: { children: React.ReactNode }) { + const { currentView, setCurrentView, isSidebarOpen, toggleSidebar } = useUIStore(); + const connection = useEngineStore((s) => s.connection); + const status = useEngineStore((s) => s.status); + + const connectionLabel = + connection === 'open' ? 'Moteur connecté' + : connection === 'connecting' ? 'Connexion…' + : 'Moteur injoignable'; + const connectionColor = + connection === 'open' ? 'bg-emerald-500' + : connection === 'connecting' ? 'bg-amber-500' + : 'bg-red-500'; + + return ( +
+ + +
+
+
{children}
+
+
+ ); +} diff --git a/ihm-v2/src/components/ui/primitives.tsx b/ihm-v2/src/components/ui/primitives.tsx new file mode 100644 index 0000000..146b6cb --- /dev/null +++ b/ihm-v2/src/components/ui/primitives.tsx @@ -0,0 +1,389 @@ +/** + * Primitives d'interface partagées par toutes les vues. + * + * Objectif : une IHM lisible et honnête. Deux principes portés par ces composants : + * 1. Chaque bloc de données affiche SA SOURCE (`source`), donc l'endpoint moteur + * dont il provient — on peut toujours remonter à l'origine d'un chiffre. + * 2. Une donnée non mesurée s'affiche « — » (via `Value`), jamais un 0 qui + * laisserait croire à une mesure réelle. + */ +import type { ReactNode } from "react"; +import { AlertTriangle, Info, Loader2 } from "lucide-react"; + +/* ── En-tête de page ─────────────────────────────────────────────────────── */ + +export function PageHeader({ + title, + description, + actions, +}: { + title: string; + description?: ReactNode; + actions?: ReactNode; +}) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ); +} + +/* ── Carte de section ────────────────────────────────────────────────────── */ + +export function Card({ + title, + subtitle, + source, + actions, + children, + className = "", +}: { + title?: string; + subtitle?: ReactNode; + /** Endpoint moteur d'où viennent les données (traçabilité). */ + source?: string; + actions?: ReactNode; + children: ReactNode; + className?: string; +}) { + return ( +
+ {(title || actions) && ( +
+
+ {title &&

{title}

} + {subtitle &&

{subtitle}

} + {source && } +
+ {actions &&
{actions}
} +
+ )} +
{children}
+
+ ); +} + +/** Badge discret indiquant l'endpoint moteur à l'origine des données affichées. */ +export function SourceTag({ source }: { source: string }) { + return ( + + {source} + + ); +} + +/* ── États ───────────────────────────────────────────────────────────────── */ + +export function LoadingState({ label = "Chargement…" }: { label?: string }) { + return ( +
+ + {label} +
+ ); +} + +/** + * Erreur affichée telle quelle : on montre le message du moteur plutôt que de + * masquer le problème derrière des données de repli. + */ +export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) { + const message = error instanceof Error ? error.message : String(error); + return ( +
+
+ +
+

Donnée indisponible

+

{message}

+
+
+ {onRetry && ( + + )} +
+ ); +} + +export function EmptyState({ children }: { children: ReactNode }) { + return

{children}

; +} + +/** + * Requête suspendue par React Query (`fetchStatus === "paused"`) : elle a été + * initiée mais n'a pas abouti, et n'a pas non plus échoué. + * + * Cet état mérite son propre rendu : il n'est ni un chargement, ni une erreur, + * et surtout pas un résultat vide. Sans lui, une vue affichait « aucune donnée » + * alors que rien n'était revenu du serveur — exactement le genre d'affirmation + * fausse que cette IHM doit éviter. Observé en conditions réelles sur + * `/api/ha/entities` quand le moteur répond 503. + */ +export function PausedState({ onRetry }: { onRetry?: () => void }) { + return ( +
+
+ +
+

Requête suspendue

+

+ L'appel a été lancé mais n'a rien renvoyé, et n'a pas été signalé en erreur. + Aucune donnée n'a été reçue — ce n'est pas un résultat vide. +

+
+
+ {onRetry && ( + + )} +
+ ); +} + +/** État minimal d'une requête, tel que les vues ont besoin de le distinguer. */ +export interface QueryLike { + status: string; + fetchStatus: string; + error: unknown; + data: unknown; + refetch?: () => unknown; +} + +export type QueryPhase = "error" | "paused" | "loading" | "ready"; + +/** + * Détermine la phase d'affichage d'une requête. + * + * Règle centrale : `ready` n'est renvoyé que si des données sont réellement + * arrivées. Une vue ne peut donc jamais afficher son état « vide » sans avoir + * reçu de réponse — c'était le défaut du motif `isLoading / isError / vide`. + */ +export function queryPhase(q: QueryLike): QueryPhase { + if (q.status === "error") return "error"; + if (q.data !== undefined && q.data !== null) return "ready"; + if (q.fetchStatus === "paused") return "paused"; + return "loading"; +} + +/* ── Encart explicatif ───────────────────────────────────────────────────── */ + +/** + * Encart pédagogique : explique ce que fait le moteur sur cet écran. Le ton est + * volontairement factuel — pas de promesse sur des comportements non vérifiés. + */ +export function Explain({ title, children }: { title?: string; children: ReactNode }) { + return ( +
+
+ +
+ {title &&

{title}

} +
{children}
+
+
+
+ ); +} + +/** Avertissement pour les actions à conséquence réelle (coût, redémarrage…). */ +export function Warn({ children }: { children: ReactNode }) { + return ( +
+
+ +
{children}
+
+
+ ); +} + +/* ── Tuile de métrique ───────────────────────────────────────────────────── */ + +export function Metric({ + label, + value, + hint, + tone = "neutral", +}: { + label: string; + value: ReactNode; + hint?: ReactNode; + tone?: "neutral" | "good" | "warn" | "bad" | "info"; +}) { + const tones = { + neutral: "text-slate-100", + good: "text-emerald-400", + warn: "text-amber-400", + bad: "text-red-400", + info: "text-sky-400", + } as const; + return ( +
+

{label}

+

{value}

+ {hint &&

{hint}

} +
+ ); +} + +/* ── Valeurs ─────────────────────────────────────────────────────────────── */ + +/** + * Affiche une valeur potentiellement non mesurée. `null`/`undefined` → « — ». + * C'est la garantie qu'un tiret signifie « jamais mesuré », pas « zéro ». + */ +export function Value({ + value, + unit, + digits, + className = "", +}: { + value: number | string | null | undefined; + unit?: string; + digits?: number; + className?: string; +}) { + if (value === null || value === undefined || value === "") { + return —; + } + const text = + typeof value === "number" && digits !== undefined ? value.toFixed(digits) : String(value); + return ( + + {text} + {unit && {unit}} + + ); +} + +/* ── Tableau ─────────────────────────────────────────────────────────────── */ + +export function Table({ children, className = "" }: { children: ReactNode; className?: string }) { + return ( +
+ {children}
+
+ ); +} + +export function Th({ + children, + onClick, + active, + desc, + align = "left", +}: { + children: ReactNode; + onClick?: () => void; + active?: boolean; + desc?: boolean; + align?: "left" | "right"; +}) { + return ( + + {children} + {active && {desc ? "↓" : "↑"}} + + ); +} + +export function Td({ + children, + align = "left", + className = "", +}: { + children: ReactNode; + align?: "left" | "right"; + className?: string; +}) { + return ( + + {children} + + ); +} + +/* ── Boutons ─────────────────────────────────────────────────────────────── */ + +export function Button({ + children, + onClick, + disabled, + variant = "primary", + type = "button", + title, + className = "", +}: { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: "primary" | "secondary" | "danger" | "ghost"; + type?: "button" | "submit"; + title?: string; + className?: string; +}) { + const variants = { + primary: "bg-sky-600 text-white hover:bg-sky-500", + secondary: "border border-slate-700 text-slate-300 hover:bg-slate-800", + danger: "border border-red-800 text-red-300 hover:bg-red-950/50", + ghost: "text-slate-400 hover:text-slate-200", + } as const; + return ( + + ); +} + +/* ── Badge d'état ────────────────────────────────────────────────────────── */ + +export function Pill({ + children, + tone = "neutral", +}: { + children: ReactNode; + tone?: "neutral" | "good" | "warn" | "bad" | "info"; +}) { + const tones = { + neutral: "bg-slate-800 text-slate-300", + good: "bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/30", + warn: "bg-amber-500/15 text-amber-300 ring-1 ring-amber-500/30", + bad: "bg-red-500/15 text-red-300 ring-1 ring-red-500/30", + info: "bg-sky-500/15 text-sky-300 ring-1 ring-sky-500/30", + } as const; + return ( + + {children} + + ); +} diff --git a/ihm-v2/src/main.tsx b/ihm-v2/src/main.tsx new file mode 100644 index 0000000..6fb19e0 --- /dev/null +++ b/ihm-v2/src/main.tsx @@ -0,0 +1,30 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import App from "./App"; +import "./index.css"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + // `networkMode: "always"` — le moteur est sur le LAN (souvent localhost) : + // l'état « en ligne » du navigateur ne dit rien de sa joignabilité. En mode + // "online" (défaut), React Query SUSPEND les requêtes dès qu'il se croit + // hors ligne : la requête n'est alors ni en cours, ni en erreur, et les vues + // affichaient leur état « aucune donnée » — un résultat vide qui n'en était + // pas un. Observé en conditions réelles sur /api/ha/entities. + networkMode: "always", + }, + mutations: { networkMode: "always" }, + }, +}); + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/ihm-v2/src/state/uiStore.ts b/ihm-v2/src/state/uiStore.ts new file mode 100644 index 0000000..6240f3d --- /dev/null +++ b/ihm-v2/src/state/uiStore.ts @@ -0,0 +1,76 @@ +/** Navigation entre vues + état sidebar (sans dépendance routeur). */ +import { create } from "zustand"; + +export type ViewKey = + // Pilotage + | "dashboard" + | "chat" + | "taskrunner" + // Domotique & vocal + | "homeassistant" + // Moteur & codage + | "agents" + | "workflow" + | "llm" + | "prompt" + | "benchmarks" + // Autonomie + | "autonomy" + // Optimisation + | "operations" + | "observability" + | "accounts" + | "config" + // Installation & docs + | "setup" + | "docs"; + +// Alias pour compatibilité avec AppLayout généré +export type View = ViewKey; + +const STORAGE_KEY = "moteur_ui_view"; + +/** Restaure le dernier onglet ouvert : un rechargement ne doit pas perdre le contexte. */ +function initialView(): ViewKey { + try { + const saved = localStorage.getItem(STORAGE_KEY); + return (saved as ViewKey) ?? "dashboard"; + } catch { + return "dashboard"; + } +} + +interface UiStore { + view: ViewKey; + currentView: ViewKey; // alias lecture seule + isSidebarOpen: boolean; + setView: (v: ViewKey) => void; + setCurrentView: (v: ViewKey) => void; + toggleSidebar: () => void; +} + +function persist(view: ViewKey) { + try { + localStorage.setItem(STORAGE_KEY, view); + } catch { + /* localStorage indisponible : la navigation reste fonctionnelle en mémoire */ + } +} + +export const useUiStore = create((set) => ({ + view: initialView(), + currentView: initialView(), + isSidebarOpen: true, + setView: (view) => { + persist(view); + set({ view, currentView: view }); + }, + setCurrentView: (view) => { + persist(view); + set({ view, currentView: view }); + }, + toggleSidebar: () => set((s) => ({ isSidebarOpen: !s.isSidebarOpen })), +})); + +// Alias pour AppLayout qui importe useUIStore (différente casse) +export const useUIStore = useUiStore; diff --git a/ihm-v2/src/types/config.ts b/ihm-v2/src/types/config.ts new file mode 100644 index 0000000..563b2e9 --- /dev/null +++ b/ihm-v2/src/types/config.ts @@ -0,0 +1,598 @@ +/** + * Registre déclaratif des paramètres éditables de config.json. + * + * C'est le cœur « paramétrable » de l'IHM : ajouter un réglage = ajouter une + * entrée ici (chemin pointé + type de contrôle + aide + effet). Chaque champ a + * été vérifié contre le code qui le lit réellement — le `impact` décrit ce que + * le moteur fait de la valeur, pas ce qu'on aimerait qu'il en fasse. + * + * Couverture : l'ancien registre exposait 17 champs sur ~45 réellement présents + * dans config.json. Les 11 réglages `auditor_*`, les listes de tiers, la + * politique de routage et 4 des 5 plafonds `budget_guard` étaient inaccessibles + * depuis l'IHM — il fallait éditer config.json à la main. + * + * NB : seuls les paramètres de config.json sont éditables (POST /api/config en + * merge). Les secrets .env (clés API, tokens) sont en lecture seule (présence + * via GET /api/keys) — aucun endpoint backend ne les écrit. + */ + +export type FieldType = "text" | "number" | "toggle" | "time" | "select" | "list"; + +export interface ConfigFieldOption { + value: string | number; + label: string; +} + +export interface ConfigField { + /** Chemin pointé dans config.json (ex: "persistent_agents.daemon_enabled"). */ + path: string; + label: string; + type: FieldType; + /** À quoi sert le réglage. */ + help: string; + /** Ce qui change concrètement dans le comportement du moteur. */ + impact?: string; + /** Valeur par défaut côté moteur si la clé est absente de config.json. */ + fallback?: string | number | boolean | string[]; + min?: number; + max?: number; + step?: number; + placeholder?: string; + /** Pour type "select". */ + options?: ConfigFieldOption[]; + /** Pour type "list" : l'ordre des entrées est signifiant. */ + ordered?: boolean; +} + +export interface ConfigSection { + id: string; + title: string; + description?: string; + /** Regroupement de haut niveau, pour la navigation latérale de la vue. */ + group: "routage" | "budgets" | "autonomie" | "performance"; + fields: ConfigField[]; +} + +const TIER_OPTIONS: ConfigFieldOption[] = [ + { value: "leger", label: "leger — rapide et gratuit" }, + { value: "moyen", label: "moyen — équilibré" }, + { value: "fort", label: "fort — raisonnement complexe" }, + { value: "automatique", label: "automatique — cascade complète" }, +]; + +export const CONFIG_SECTIONS: ConfigSection[] = [ + /* ── Routage ───────────────────────────────────────────────────────────── */ + { + id: "models", + title: "Modèles par agent", + group: "routage", + description: + "Chaque agent peut recevoir un modèle précis (ex: deepseek-reasoner) ou un tier (leger/moyen/fort). Un tier laisse la cascade choisir ; un modèle précis la court-circuite.", + fields: [ + { + path: "planner_model", + label: "Modèle du Planner", + type: "text", + help: "Modèle ou tier utilisé par le PlannerAgent pour décomposer une tâche en DAG.", + impact: "Un planner faible produit des plans incohérents, ce qui coûte plus cher en aval qu'un planner solide.", + placeholder: "ex: deepseek-reasoner ou fort", + }, + { + path: "executor_model", + label: "Modèle de l'Executor", + type: "text", + help: "Modèle ou tier utilisé par l'ExecutorAgent pour exécuter chaque étape.", + impact: "C'est l'agent le plus sollicité : son modèle domine la facture et la latence globale.", + placeholder: "ex: gemini-3.5-flash-free ou leger", + }, + { + path: "ha_model", + label: "Modèle Home Assistant", + type: "text", + help: "Modèle ou tier de l'agent qui traduit une demande en appel de service HA.", + impact: "Utilisé par l'assistant vocal : un tier leger réduit nettement le délai de réponse perçu.", + placeholder: "ex: moyen", + }, + { + path: "antigravity_model", + label: "Modèle Antigravity", + type: "text", + help: "Modèle utilisé par l'AntigravityAgent (intégration IDE).", + placeholder: "ex: gemini-3.1-pro-preview-paid", + }, + { + path: "persistent_agents.routines_model", + label: "Modèle des routines", + type: "select", + help: "Tier utilisé par les routines de fond (préparation d'articles, veille).", + options: TIER_OPTIONS, + fallback: "fort", + }, + ], + }, + { + id: "tiers", + title: "Composition des tiers", + group: "routage", + description: + "L'ordre compte : le moteur essaie les modèles de haut en bas et s'arrête au premier qui répond. Mettre un modèle gratuit en tête d'un tier très sollicité est le levier d'économie le plus direct.", + fields: [ + { + path: "tiers.leger", + label: "Tier « leger »", + type: "list", + ordered: true, + help: "Modèles essayés pour les tâches simples et les réponses vocales.", + impact: "Le premier modèle disponible de la liste traite la requête.", + placeholder: "id du modèle…", + }, + { + path: "tiers.moyen", + label: "Tier « moyen »", + type: "list", + ordered: true, + help: "Modèles pour les tâches standard.", + placeholder: "id du modèle…", + }, + { + path: "tiers.fort", + label: "Tier « fort »", + type: "list", + ordered: true, + help: "Modèles pour le raisonnement complexe et l'escalade qualité.", + impact: "Ce sont généralement les modèles payants : leur ordre pilote directement la dépense.", + placeholder: "id du modèle…", + }, + { + path: "tiers.automatique", + label: "Tier « automatique »", + type: "list", + ordered: true, + help: "Cascade complète utilisée quand aucun tier n'est imposé.", + placeholder: "id du modèle…", + }, + ], + }, + { + id: "routing_policy", + title: "Politique de routage", + group: "routage", + description: + "Garde-fous appliqués quelle que soit la composition des tiers, y compris aux modèles venant de models_registry.db.", + fields: [ + { + path: "routing_policy.excluded_models", + label: "Modèles exclus du routage automatique", + type: "list", + help: "Ces modèles ne seront jamais choisis automatiquement, même s'ils figurent dans un tier ou dans le catalogue.", + impact: + "Filtre appliqué par core/llm_gateway.py::_resolve_tier_models. Le modèle reste appelable explicitement (query_llm_direct). Sert à empêcher l'auto-escalade vers un modèle très coûteux.", + placeholder: "ex: claude-fable-5", + }, + ], + }, + { + id: "escalation", + title: "Escalade qualité", + group: "routage", + description: + "Après revue, une réponse jugée trop faible peut être rejouée sur un tier supérieur — une seule fois.", + fields: [ + { + path: "cascade_quality_escalation.enabled", + label: "Activer l'escalade qualité", + type: "toggle", + help: "Rejoue la tâche sur un tier plus puissant si le score de qualité est sous le seuil.", + impact: "Améliore le résultat, mais double le coût de la tâche concernée.", + fallback: true, + }, + { + path: "cascade_quality_escalation.quality_threshold", + label: "Seuil de qualité", + type: "number", + help: "Score en dessous duquel la tâche est relancée (échelle 0–10).", + impact: "Plus le seuil est haut, plus l'escalade se déclenche souvent — et plus la dépense monte.", + fallback: 6.0, + min: 0, + max: 10, + step: 0.5, + }, + { + path: "cascade_quality_escalation.escalated_tier", + label: "Tier d'escalade", + type: "select", + help: "Tier utilisé pour la seconde tentative.", + options: TIER_OPTIONS, + fallback: "fort", + }, + { + path: "cascade_quality_escalation.eligible_domains", + label: "Domaines éligibles", + type: "list", + help: "Seules les tâches de ces domaines peuvent être escaladées.", + impact: "Restreindre cette liste limite les escalades coûteuses aux cas où elles servent vraiment.", + placeholder: "ex: code_generation", + }, + ], + }, + + /* ── Budgets ───────────────────────────────────────────────────────────── */ + { + id: "budgets_session", + title: "Garde-fous par exécution", + group: "budgets", + description: "Limites appliquées à une seule exécution. 0 = désactivé.", + fields: [ + { + path: "max_session_tokens", + label: "Budget max de tokens / session", + type: "number", + help: "Nombre maximum de tokens qu'une exécution peut consommer avant arrêt.", + impact: "Empêche une boucle d'agents de consommer sans limite.", + fallback: 500000, + min: 0, + step: 1000, + }, + { + path: "max_execution_seconds", + label: "Durée max d'une session (s)", + type: "number", + help: "Durée maximale d'une exécution avant arrêt automatique.", + impact: "Protège contre un agent bloqué sur un outil qui ne rend jamais la main.", + fallback: 0, + min: 0, + }, + { + path: "max_execution_cost_usd", + label: "Coût max / session (USD)", + type: "number", + help: "Plafond de coût d'une exécution.", + fallback: 0, + min: 0, + step: 0.01, + }, + { + path: "auto_review", + label: "Revue automatique post-DAG", + type: "toggle", + help: "Le ReviewerAgent analyse le résultat et peut demander des corrections.", + impact: "Améliore la fiabilité, ajoute un appel LLM par exécution.", + fallback: true, + }, + { + path: "hitl.interactive_auto_approve", + label: "Auto-approbation des plans à risque", + type: "toggle", + help: "Approuve automatiquement les plans jugés risqués en session interactive.", + impact: "⚠ Supprime le point de contrôle humain avant les actions sensibles. À laisser désactivé.", + fallback: false, + }, + ], + }, + { + id: "budget_guard", + title: "Plafonds globaux (BudgetGuard)", + group: "budgets", + description: + "Vérifiés avant chaque décision de cascade : ils déterminent si le moteur a encore le droit d'appeler un provider payant.", + fields: [ + { + path: "budget_guard.daily_budget_usd", + label: "Budget journalier (USD)", + type: "number", + help: "Plafond de dépense journalier pour les API payantes.", + impact: "Atteint, la cascade cesse de router vers les providers payants et se rabat sur le gratuit.", + fallback: 0.5, + min: 0, + step: 0.05, + }, + { + path: "budget_guard.total_daily_budget_usd", + label: "Budget journalier total (USD)", + type: "number", + help: "Plafond couvrant l'ensemble des appels, tous providers confondus.", + fallback: 1.0, + min: 0, + step: 0.05, + }, + { + path: "budget_guard.gemini_free_tokens_per_hour", + label: "Quota Gemini gratuit (tokens/h)", + type: "number", + help: "Volume horaire au-delà duquel le moteur considère le Free Tier Gemini épuisé.", + impact: "Sert à anticiper le 429 plutôt qu'à le subir : la cascade bascule avant d'être rejetée.", + fallback: 1000000, + min: 0, + step: 10000, + }, + { + path: "budget_guard.deepseek_free_requests_per_day", + label: "Quota DeepSeek gratuit (req/jour)", + type: "number", + help: "Nombre de requêtes quotidiennes avant de considérer le palier gratuit DeepSeek épuisé.", + fallback: 200, + min: 0, + }, + { + path: "budget_guard.auditor_weekly_budget_usd", + label: "Budget hebdo de l'Auditeur (USD)", + type: "number", + help: "Plafond dédié aux passages de l'agent auditeur.", + impact: "L'auditeur analyse de gros volumes de code : ce plafond l'empêche de dériver.", + fallback: 1.0, + min: 0, + step: 0.1, + }, + ], + }, + + /* ── Autonomie ─────────────────────────────────────────────────────────── */ + { + id: "daemon", + title: "Daemon Sentinelle", + group: "autonomie", + description: "Agent de fond qui surveille en continu la santé du système (Git, Home Assistant, services).", + fields: [ + { + path: "persistent_agents.daemon_enabled", + label: "Activer le Daemon", + type: "toggle", + help: "Boucle de surveillance permanente.", + impact: "Consomme un appel LLM par cycle : l'intervalle pilote directement ce coût récurrent.", + fallback: true, + }, + { + path: "persistent_agents.daemon_interval_minutes", + label: "Intervalle du Daemon (min)", + type: "number", + help: "Fréquence du cycle de vérification.", + impact: "10 min ≈ 144 cycles/jour. Doubler l'intervalle divise par deux le coût du daemon.", + fallback: 10, + min: 1, + }, + { + path: "persistent_agents.daemon_model", + label: "Tier du Daemon", + type: "select", + help: "Puissance allouée à chaque cycle de surveillance.", + impact: "Un tier leger suffit pour de la surveillance : inutile de payer du raisonnement ici.", + options: TIER_OPTIONS, + fallback: "leger", + }, + ], + }, + { + id: "dreamer", + title: "Dreamer & DreamCoder", + group: "autonomie", + description: + "Le Dreamer consolide la mémoire la nuit. Le DreamCoder puise dans le backlog et code de façon autonome sur une branche Git.", + fields: [ + { + path: "persistent_agents.dreamer_enabled", + label: "Activer le Dreamer", + type: "toggle", + help: "Consolidation mémoire nocturne et traitement du backlog.", + fallback: true, + }, + { + path: "persistent_agents.dreamer_schedule", + label: "Heure de déclenchement", + type: "time", + help: "Heure du cycle nocturne (HH:MM).", + fallback: "02:00", + }, + { + path: "persistent_agents.dreamer_model", + label: "Tier du Dreamer", + type: "select", + help: "Puissance allouée à la consolidation mémoire.", + options: TIER_OPTIONS, + fallback: "leger", + }, + { + path: "persistent_agents.dreamer_idle_trigger_hours", + label: "Déclenchement sur inactivité (h)", + type: "number", + help: "Heures sans activité au-delà desquelles le Dreamer se lance aussi, hors horaire.", + impact: "0 désactive ce déclencheur ; seul l'horaire nocturne reste actif.", + fallback: 3, + min: 0, + }, + { + path: "persistent_agents.dreamcoder_max_cycle_minutes", + label: "Durée max d'un cycle DreamCoder (min)", + type: "number", + help: "Borne la durée d'un cycle de traitement du backlog.", + impact: "Empêche un cycle de déborder sur la journée.", + fallback: 30, + min: 1, + }, + { + path: "persistent_agents.dreamcoder_repo_path", + label: "Dépôt Git du DreamCoder", + type: "text", + help: "Chemin du dépôt dans lequel le DreamCoder crée ses branches. Vide = dépôt du moteur.", + impact: "⚠ Le DreamCoder écrit réellement du code et crée des branches dans ce dépôt.", + placeholder: "/chemin/vers/le/depot", + }, + ], + }, + { + id: "auditor", + title: "Agent Auditeur", + group: "autonomie", + description: + "Audite périodiquement le code du moteur et remonte ses constats. Désactivé par défaut — activation explicite requise.", + fields: [ + { + path: "persistent_agents.auditor_enabled", + label: "Activer l'Auditeur", + type: "toggle", + help: "Analyse autonome du code, par lots.", + impact: "Lit de gros volumes de code : surveillez le budget hebdomadaire dédié.", + fallback: false, + }, + { + path: "persistent_agents.auditor_trigger_mode", + label: "Mode de déclenchement", + type: "select", + help: "Ce qui lance un passage d'audit.", + impact: + "« task_count » déclenche après N tâches DreamCoder terminées ; « schedule » à un créneau hebdomadaire fixe ; « both » à la première des deux conditions.", + options: [ + { value: "task_count", label: "task_count — après N tâches terminées" }, + { value: "schedule", label: "schedule — créneau hebdomadaire" }, + { value: "both", label: "both — l'un ou l'autre" }, + ], + fallback: "task_count", + }, + { + path: "persistent_agents.auditor_trigger_task_count", + label: "Nombre de tâches déclencheur", + type: "number", + help: "Tâches DreamCoder terminées depuis le dernier audit avant d'en relancer un.", + fallback: 10, + min: 1, + }, + { + path: "persistent_agents.auditor_schedule_weekday", + label: "Jour de l'audit hebdomadaire", + type: "select", + help: "Jour du créneau fixe (modes « schedule » et « both »).", + options: [ + { value: 0, label: "Lundi" }, + { value: 1, label: "Mardi" }, + { value: 2, label: "Mercredi" }, + { value: 3, label: "Jeudi" }, + { value: 4, label: "Vendredi" }, + { value: 5, label: "Samedi" }, + { value: 6, label: "Dimanche" }, + ], + fallback: 6, + }, + { + path: "persistent_agents.auditor_schedule_time", + label: "Heure de l'audit", + type: "time", + help: "Heure du créneau hebdomadaire (HH:MM).", + impact: "La boucle vérifie toutes les 15 min : le déclenchement a lieu dans le quart d'heure suivant.", + fallback: "03:00", + }, + { + path: "persistent_agents.auditor_scopes", + label: "Périmètres audités", + type: "list", + help: "Dossiers du moteur soumis à l'audit.", + impact: "Réduire la liste réduit le corpus analysé, donc le coût de chaque passage.", + placeholder: "ex: core", + }, + { + path: "persistent_agents.auditor_model_tier", + label: "Tier de l'Auditeur", + type: "select", + help: "Puissance utilisée pour l'analyse initiale.", + options: TIER_OPTIONS, + fallback: "moyen", + }, + { + path: "persistent_agents.auditor_escalation_tier", + label: "Tier d'escalade de l'Auditeur", + type: "select", + help: "Tier utilisé quand un constat mérite une analyse approfondie.", + options: TIER_OPTIONS, + fallback: "fort", + }, + { + path: "persistent_agents.auditor_confidence_threshold", + label: "Seuil de confiance", + type: "number", + help: "Confiance minimale (0–10) pour qu'un constat soit retenu.", + impact: "Monter le seuil réduit le bruit, au risque de manquer de vrais problèmes.", + fallback: 6.0, + min: 0, + max: 10, + step: 0.5, + }, + { + path: "persistent_agents.auditor_dedup_similarity_threshold", + label: "Seuil de déduplication", + type: "number", + help: "Similarité (0–1) au-delà de laquelle deux constats sont considérés identiques.", + fallback: 0.75, + min: 0, + max: 1, + step: 0.05, + }, + { + path: "persistent_agents.auditor_max_corpus_chars", + label: "Taille max du corpus (caractères)", + type: "number", + help: "Volume de code maximal envoyé au modèle en une passe.", + impact: "Borne directement le coût en tokens d'entrée de chaque audit.", + fallback: 150000, + min: 1000, + step: 10000, + }, + ], + }, + + /* ── Performance ───────────────────────────────────────────────────────── */ + { + id: "cache", + title: "Cache sémantique", + group: "performance", + description: + "Compare la requête entrante à l'historique par embeddings. Au-dessus du seuil, la réponse en cache est renvoyée sans appel LLM.", + fields: [ + { + path: "semantic_cache.enabled", + label: "Activer le cache sémantique", + type: "toggle", + help: "Réutilise les réponses pour des requêtes sémantiquement proches.", + impact: + "Réponse instantanée et coût nul sur les redites — précieux pour les commandes vocales répétitives.", + fallback: false, + }, + { + path: "semantic_cache.similarity_threshold", + label: "Seuil de similarité", + type: "number", + help: "Similarité minimale (0–1) pour réutiliser une réponse en cache.", + impact: + "Trop bas, le moteur ressort une réponse hors sujet. 0,95 est un compromis prudent.", + fallback: 0.95, + min: 0, + max: 1, + step: 0.01, + }, + ], + }, +]; + +/** Groupes de sections, pour la navigation de la vue Configuration. */ +export const CONFIG_GROUPS: Array<{ id: ConfigSection["group"]; label: string; description: string }> = [ + { id: "routage", label: "Routage & modèles", description: "Qui traite quoi, et dans quel ordre." }, + { id: "budgets", label: "Budgets & garde-fous", description: "Ce que le moteur a le droit de dépenser." }, + { id: "autonomie", label: "Agents autonomes", description: "Ce que le moteur fait sans vous." }, + { id: "performance", label: "Performance", description: "Latence et réutilisation." }, +]; + +/** Liste des secrets .env affichés (lecture seule) — alignée sur GET /api/keys. */ +export const SECRET_LABELS: Record = { + GEMINI_API_KEY: "Gemini (Free Tier)", + GEMINI_PAYANT_API_KEY: "Gemini (payant / GCP)", + DEEPSEEK_API_KEY: "DeepSeek", + ANTHROPIC_API_KEY: "Anthropic", + OPENROUTER_API_KEY: "OpenRouter", + MISTRAL_API_KEY: "Mistral", + GROQ_API_KEY: "Groq", + COHERE_API_KEY: "Cohere", + XAI_API_KEY: "xAI (Grok)", + MINIMAX_API_KEY: "MiniMax", + DEEPINFRA_API_KEY: "DeepInfra", + ZHIPU_API_KEY: "Zhipu / GLM", + GITHUB_TOKEN: "GitHub Models", + HASS_TOKEN: "Home Assistant (token)", + MOTEUR_API_KEY: "Clé API du moteur", +}; diff --git a/ihm-v2/src/views/APIAccounts.tsx b/ihm-v2/src/views/APIAccounts.tsx new file mode 100644 index 0000000..eae6564 --- /dev/null +++ b/ihm-v2/src/views/APIAccounts.tsx @@ -0,0 +1,216 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js"; +import { Doughnut } from "react-chartjs-2"; + +import { fetchAccounts, fetchCostPerSuccess, setManualBalance } from "../api/accounts"; +import type { ProviderAccount } from "../api/accounts"; +import { useUiStore } from "../state/uiStore"; + +ChartJS.register(ArcElement, Tooltip, Legend); + +const STATUS_COLORS: Record = { + active: "text-emerald-400", + manuel: "text-sky-400", + non_suivi: "text-slate-500", +}; + +function AccountCard({ account }: { account: ProviderAccount }) { + const isAlert = account.used_pct > 80; + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(false); + const [inputValue, setInputValue] = useState(account.balance_usd !== null ? String(account.balance_usd) : ""); + const canEditManually = account.status === "non_suivi" || account.status === "manuel"; + + const manualMut = useMutation({ + mutationFn: (value: number) => setManualBalance(account.provider_key ?? account.name, value), + onSuccess: () => { + setEditing(false); + queryClient.invalidateQueries({ queryKey: ["accounts"] }); + }, + }); + + return ( +
+ {isAlert &&
} + +
+
+

{account.name}

+

{account.plan}

+
+ + ● {account.status} + +
+ +
+ Solde actuel + {editing ? ( +
+ $ + setInputValue(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && manualMut.mutate(parseFloat(inputValue))} + className="w-24 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-lg text-white" + /> + + +
+ ) : ( +
+ + {account.balance_usd !== null + ? `$${account.balance_usd.toFixed(2)}` + : account.balance_eur !== null + ? `${account.balance_eur.toFixed(2)}€` + : "N/A"} + + {canEditManually && ( + + )} +
+ )} +
+ +
+
+
+ Quota RPM + {account.used_rpm} / {account.quota_rpm || "∞"} +
+
+
0.8 ? "bg-orange-500" : "bg-sky-500"}`} + style={{ width: `${Math.min(100, ((account.used_rpm ?? 0) / (account.quota_rpm || 1)) * 100)}%` }} + /> +
+
+
+
+ Quota RPD + {account.used_rpd} / {account.quota_rpd || "∞"} +
+
+
80 ? "bg-orange-500" : "bg-sky-500"}`} + style={{ width: `${Math.min(100, account.used_pct)}%` }} + /> +
+
+
+ +
+ Sync: {account.last_sync} + {account.dashboard_url && ( + + )} +
+
+ ); +} + +export function APIAccounts() { + const { data: billing, isLoading } = useQuery({ + queryKey: ["accounts"], + queryFn: fetchAccounts, + }); + const accounts = billing?.providers ?? []; + const totalCostMonth = billing?.total_cost_usd_month ?? 0; + + const { data: costsData } = useQuery({ + queryKey: ["costs"], + queryFn: fetchCostPerSuccess, + }); + + const setView = useUiStore((s) => s.setView); + + if (isLoading) return

Chargement des comptes…

; + + const chartData = { + labels: costsData?.by_provider.map(c => c.provider) || [], + datasets: [{ + data: costsData?.by_provider.map(c => c.cost_usd) || [], + backgroundColor: ['#0ea5e9', '#8b5cf6', '#10b981', '#f59e0b', '#64748b'], + borderWidth: 0, + }] + }; + + return ( +
+ {/* Bannière Top */} +
+
+

Dépensé ce mois-ci

+

${totalCostMonth.toFixed(2)}

+
+
+ {costsData && } +
+
+ + {/* Grille Providers */} +
+ {accounts.map(acc => ( + + ))} +
+ + {/* Sections Spéciales */} +
+ {/* Google Workspace — le moteur n'expose aucun endpoint d'état OAuth ni de + réautorisation. Plutôt que d'afficher une liste de scopes codée en dur et + un bouton qui échouait en 404, on indique la procédure réelle. */} +
+

Google Workspace

+

+ L'accès OAuth (Agenda, Gmail, Drive, Sheets) s'obtient côté serveur : le moteur + n'expose pas de route de réautorisation. +

+
+ python setup_google_oauth.py +
+

+ Le jeton de rafraîchissement obtenu est écrit dans google_token.json. + L'état des outils Workspace se vérifie en les appelant depuis le Chat. +

+
+ + {/* Home Assistant — l'état réel est mesuré dans la vue Domotique & Vocal + (GET /api/ha/health). Ici figurait une pastille « ● Connecté » codée en + dur et un bouton « Tester Ping » sans action. */} +
+

Home Assistant

+

+ La liaison domotique est testée en direct, avec sa latence et le détail des erreurs. +

+ +
+
+
+ ); +} diff --git a/ihm-v2/src/views/Autonomy.tsx b/ihm-v2/src/views/Autonomy.tsx new file mode 100644 index 0000000..11a2e71 --- /dev/null +++ b/ihm-v2/src/views/Autonomy.tsx @@ -0,0 +1,477 @@ +/** + * Vue « Agents autonomes ». + * + * Nouvelle section : le moteur travaille en continu sans l'utilisateur (Daemon + * Sentinelle toutes les 10 min, Dreamer nocturne, DreamCoder sur le backlog, + * Auditeur périodique). Jusqu'ici, seul un interrupteur on/off était exposé dans + * la vue Opérations — impossible de savoir ce que ces agents avaient fait, ni + * pourquoi. + * + * L'objectif de cette page est de rendre l'autonomie lisible : ce qui tourne, + * quand, ce que ça a trouvé, et ce que ça coûte. + */ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Shield, Moon, Search as SearchIcon, Play, AlertTriangle, CheckCircle2, Clock, +} from 'lucide-react'; +import { + fetchDaemonStatus, fetchDaemonLogs, fetchDreamerStatus, triggerDreamer, + fetchPersistentConfig, updatePersistentConfig, + type DaemonAnomaly, +} from '../api/autonomy'; +import { + PageHeader, Card, Explain, Warn, Value, Button, Metric, Pill, Table, Th, Td, + LoadingState, ErrorState, EmptyState, PausedState, queryPhase, +} from '../components/ui/primitives'; + +const REFRESH_MS = 15_000; + +function relativeTime(iso: string | null): string { + if (!iso) return '—'; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return iso; + const diffMin = Math.round((Date.now() - then) / 60000); + if (diffMin < 1) return "à l'instant"; + if (diffMin < 60) return `il y a ${diffMin} min`; + const h = Math.floor(diffMin / 60); + if (h < 24) return `il y a ${h} h`; + return `il y a ${Math.floor(h / 24)} j`; +} + +function anomalyTone(status: string): 'good' | 'warn' | 'bad' | 'info' { + if (status === 'ok') return 'good'; + if (status === 'warning') return 'warn'; + if (status === 'error') return 'bad'; + return 'info'; +} + +/* ── Interrupteur générique sur persistent_agents ─────────────────────────── */ + +function EnableToggle({ configKey, enabled }: { configKey: string; enabled: boolean }) { + const queryClient = useQueryClient(); + const mut = useMutation({ + mutationFn: (next: boolean) => updatePersistentConfig({ [configKey]: next }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['autonomy'] }); + void queryClient.invalidateQueries({ queryKey: ['config'] }); + }, + }); + + return ( + + ); +} + +/* ── Daemon Sentinelle ────────────────────────────────────────────────────── */ + +function DaemonCard() { + const q = useQuery({ + queryKey: ['autonomy', 'daemon'], + queryFn: fetchDaemonStatus, + refetchInterval: REFRESH_MS, + }); + + return ( + } + > + {queryPhase(q) === 'loading' ? ( + + ) : queryPhase(q) === 'paused' ? ( + q.refetch()} /> + ) : queryPhase(q) === 'error' ? ( + q.refetch()} /> + ) : q.data ? ( +
+
+ + {q.data.running ? 'en marche' : q.data.enabled ? 'activé, arrêté' : 'désactivé'} + } + /> + + + 0 ? 'bad' : 'good'} + /> +
+ + {q.data.last_error && ( +

+ {q.data.last_error} +

+ )} + +
+

+ Constats du dernier cycle ({q.data.anomalies.length}) +

+ {q.data.anomalies.length === 0 ? ( +

+ Aucune anomalie détectée. +

+ ) : ( +
+ {q.data.anomalies.map((a: DaemonAnomaly, i) => ( +
+ +
+
+ {a.check} + {a.status} +
+

{a.alert}

+
+
+ ))} +
+ )} +
+
+ ) : null} +
+ ); +} + +function DaemonLogsCard() { + const q = useQuery({ + queryKey: ['autonomy', 'daemon-logs'], + queryFn: () => fetchDaemonLogs(30), + refetchInterval: REFRESH_MS, + }); + + return ( + + {queryPhase(q) === 'loading' ? ( + + ) : queryPhase(q) === 'paused' ? ( + q.refetch()} /> + ) : queryPhase(q) === 'error' ? ( + q.refetch()} /> + ) : (q.data?.logs.length ?? 0) === 0 ? ( + Aucun cycle enregistré depuis le démarrage du moteur. + ) : ( +
+ + + + + + + + + + + {q.data!.logs.map((log) => ( + + + + + + + ))} + +
HorodatageDuréeConstatsVérifications
+ {new Date(log.timestamp).toLocaleString('fr-FR')} + + {Math.round(log.duration_ms)} ms + + 0 ? 'text-amber-400' : 'text-emerald-400'}> + {log.anomalies_count} + + +
+ {Object.entries(log.checks_summary ?? {}).map(([check, status]) => ( + + {check} + + ))} +
+
+
+ )} +
+ ); +} + +/* ── Dreamer ──────────────────────────────────────────────────────────────── */ + +function DreamerCard() { + const [confirming, setConfirming] = useState(false); + const queryClient = useQueryClient(); + + const q = useQuery({ + queryKey: ['autonomy', 'dreamer'], + queryFn: fetchDreamerStatus, + refetchInterval: REFRESH_MS, + }); + + const trigger = useMutation({ + mutationFn: triggerDreamer, + onSuccess: () => { + setConfirming(false); + void queryClient.invalidateQueries({ queryKey: ['autonomy', 'dreamer'] }); + }, + }); + + const actions = q.data?.last_report?.actions ?? {}; + + return ( + + + {q.data && } +
+ } + > + {queryPhase(q) === 'loading' ? ( + + ) : queryPhase(q) === 'paused' ? ( + q.refetch()} /> + ) : queryPhase(q) === 'error' ? ( + q.refetch()} /> + ) : q.data ? ( +
+ {confirming && ( + +
+

+ Un cycle manuel consomme de vrais appels LLM et peut modifier + la mémoire du moteur ainsi que le dépôt Git configuré pour le DreamCoder. +

+
+ + +
+
+
+ )} + +
+ + {q.data.running ? 'cycle en cours' : q.data.enabled ? 'en veille' : 'désactivé'} + } + /> + + + } + /> +
+ + {q.data.last_report?.summary && ( +
+

Résumé du dernier rapport

+

{q.data.last_report.summary}

+
+ )} + + {Object.keys(actions).length > 0 && ( +
+

Actions du dernier cycle

+
+ {Object.entries(actions).map(([name, detail]) => ( +
+

{name}

+
+ {Object.entries(detail ?? {}).map(([k, v]) => ( +
+ {k} + + {v === null || v === undefined || v === '' ? '—' : String(v).slice(0, 60)} + +
+ ))} +
+
+ ))} +
+
+ )} + + {trigger.isError && } + {trigger.isSuccess && ( +

Cycle terminé — statut rafraîchi.

+ )} +
+ ) : null} + + ); +} + +/* ── Auditeur ─────────────────────────────────────────────────────────────── */ + +function AuditorCard() { + const q = useQuery({ + queryKey: ['autonomy', 'persistent-config'], + queryFn: fetchPersistentConfig, + refetchInterval: REFRESH_MS, + }); + + const cfg = q.data ?? {}; + const enabled = Boolean(cfg.auditor_enabled); + + return ( + } + > + {queryPhase(q) === 'loading' ? ( + + ) : queryPhase(q) === 'paused' ? ( + q.refetch()} /> + ) : queryPhase(q) === 'error' ? ( + q.refetch()} /> + ) : ( +
+
+ {enabled ? 'activé' : 'désactivé'}} + /> + + } /> + +
+ + {Array.isArray(cfg.auditor_scopes) && ( +
+

Périmètres audités

+
+ {(cfg.auditor_scopes as string[]).map((s) => ( + + {s} + + ))} +
+
+ )} + +

+ Le moteur n'expose pas de statut d'exécution pour l'auditeur : contrairement au + daemon et au dreamer, il n'a pas d'endpoint /status. Ce qui est + affiché ici est sa configuration, pas son activité. Ses constats + arrivent dans le backlog. Les 11 réglages détaillés sont dans{' '} + Configuration → Agents autonomes. +

+
+ )} +
+ ); +} + +/* ── Page ─────────────────────────────────────────────────────────────────── */ + +export function Autonomy() { + return ( +
+ + +
+ +

+ + Le Daemon Sentinelle + {' '} + tourne en permanence et vérifie l'état du système à intervalle fixe. Il ne + corrige rien : il constate et alerte. +

+

+ + Le Dreamer + {' '} + se déclenche la nuit (ou après une longue inactivité) pour consolider la mémoire. + Sa variante DreamCoder puise dans le backlog et écrit réellement du code sur une + branche Git dédiée. +

+

+ + L'Auditeur + {' '} + relit le code du moteur par lots. Désactivé par défaut, car il consomme beaucoup + de tokens d'entrée. +

+

+ + Chaque cycle consomme des appels LLM. L'intervalle du daemon est le réglage qui + pèse le plus sur le coût de fonctionnement au repos. +

+
+ + +
+ + +
+ +
+
+ ); +} + +export default Autonomy; diff --git a/ihm-v2/src/views/Benchmarks.tsx b/ihm-v2/src/views/Benchmarks.tsx new file mode 100644 index 0000000..998cd1d --- /dev/null +++ b/ihm-v2/src/views/Benchmarks.tsx @@ -0,0 +1,488 @@ +/** + * Vue « Comparatif modèles ». + * + * Remplace l'ancienne page Benchmarks, dont les trois tableaux étaient des + * données inventées : les endpoints appelés renvoyaient 404 et le module API + * repliait silencieusement sur des mocks. Ici, tout provient de mesures réelles, + * et ce qui n'a jamais été mesuré s'affiche « — ». + */ +import { useMemo, useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Play, Trophy, Timer, Coins } from 'lucide-react'; +import { + fetchBenchmarks, + runBenchmark, + fetchElo, + type ObservedModelPerf, + type BenchmarkRun, +} from '../api/benchmarks'; +import { fetchModels } from '../api/models'; +import { + PageHeader, Card, Explain, Warn, Table, Th, Td, Value, Button, + LoadingState, ErrorState, EmptyState, PausedState, queryPhase, Metric, Pill, +} from '../components/ui/primitives'; + +const MAX_MODELS = 8; // aligné sur _BENCH_MAX_MODELS côté backend + +type ObsSortKey = 'calls' | 'cost_usd' | 'avg_cost_per_call_usd' | 'avg_latency_ms' | 'elo'; + +/* ── Lancer un comparatif ─────────────────────────────────────────────────── */ + +function RunBenchmarkCard() { + const queryClient = useQueryClient(); + const [prompt, setPrompt] = useState('Explique en trois phrases ce qu\'est un circuit breaker.'); + const [selected, setSelected] = useState([]); + const [maxTokens, setMaxTokens] = useState(512); + + const modelsQ = useQuery({ queryKey: ['models'], queryFn: fetchModels }); + + // Seuls les modèles réellement câblés dans le gateway peuvent être appelés. + const candidates = useMemo( + () => (modelsQ.data ?? []).filter((m) => m.is_wired && m.enabled), + [modelsQ.data], + ); + + const run = useMutation({ + mutationFn: () => runBenchmark(prompt, selected, { maxTokens }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['benchmarks'] }), + }); + + function toggle(id: string) { + setSelected((cur) => + cur.includes(id) ? cur.filter((m) => m !== id) : cur.length < MAX_MODELS ? [...cur, id] : cur, + ); + } + + const freeSelected = selected.filter((id) => + candidates.find((c) => c.id === id && (c.cost_output_per_m ?? 0) === 0), + ).length; + const paidSelected = selected.length - freeSelected; + + return ( + +
+ + Ce bouton déclenche de vrais appels LLM, facturés selon le modèle. + La consommation est comptabilisée dans le suivi de tokens du moteur, comme n'importe + quel appel. Maximum {MAX_MODELS} modèles par comparatif. + + +
+ +